From f4709cae75240959ee34eaf34f746c5ee1a530c6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 11 Sep 2026 22:28:36 +0000 Subject: [PATCH 1/3] feat(cli): add hidden exp update-user-email command --- cli/exp_update_user_email.go | 81 +++++++++++ cli/exp_update_user_email_test.go | 167 ++++++++++++++++++++++ cli/root.go | 1 + coderd/database/dbauthz/dbauthz.go | 16 +++ coderd/database/dbauthz/dbauthz_test.go | 7 + coderd/database/dbmetrics/querymetrics.go | 8 ++ coderd/database/dbmock/dbmock.go | 15 ++ coderd/database/querier.go | 1 + coderd/database/querier_test.go | 130 +++++++++++++++++ coderd/database/queries.sql.go | 48 +++++++ coderd/database/queries/users.sql | 13 ++ codersdk/users.go | 21 +++ site/src/api/typesGenerated.ts | 10 ++ 13 files changed, 518 insertions(+) create mode 100644 cli/exp_update_user_email.go create mode 100644 cli/exp_update_user_email_test.go diff --git a/cli/exp_update_user_email.go b/cli/exp_update_user_email.go new file mode 100644 index 00000000000..c31558dfb73 --- /dev/null +++ b/cli/exp_update_user_email.go @@ -0,0 +1,81 @@ +package cli + +import ( + "fmt" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func (r *RootCmd) updateUserEmail() *serpent.Command { + var ( + oldEmail string + newEmail string + ) + + cmd := &serpent.Command{ + Use: "update-user-email", + Short: "Update a user's email address (break-glass; experimental)", + Hidden: true, + Options: serpent.OptionSet{ + { + Flag: "old-email", + Description: "Current email address of the user to update.", + Required: true, + Value: serpent.StringOf(&oldEmail), + }, + { + Flag: "new-email", + Description: "New email address to assign to the user.", + Required: true, + Value: serpent.StringOf(&newEmail), + }, + cliui.SkipPromptOption(), + }, + Handler: func(inv *serpent.Invocation) error { + if oldEmail == "" { + return xerrors.Errorf("--old-email is required") + } + if newEmail == "" { + return xerrors.Errorf("--new-email is required") + } + + _, _ = fmt.Fprintf(inv.Stdout, + "This will update the email address for the account currently using %q to %q.\n"+ + "All Coder sessions and API tokens for that user will be revoked.\n"+ + "If the user logs in with an external identity provider, it may overwrite the email when the user next signs in.\n", + oldEmail, newEmail, + ) + + _, err := cliui.Prompt(inv, cliui.PromptOptions{ + Text: "Confirm email update?", + IsConfirm: true, + Default: cliui.ConfirmNo, + }) + if err != nil { + return err + } + + client, err := r.InitClient(inv) + if err != nil { + return err + } + + err = client.UpdateUserEmail(inv.Context(), codersdk.UpdateUserEmailRequest{ + OldEmail: oldEmail, + NewEmail: newEmail, + }) + if err != nil { + return xerrors.Errorf("update user email: %w", err) + } + + _, _ = fmt.Fprintf(inv.Stdout, "Updated user email from %s to %s.\n", oldEmail, newEmail) + return nil + }, + } + + return cmd +} diff --git a/cli/exp_update_user_email_test.go b/cli/exp_update_user_email_test.go new file mode 100644 index 00000000000..f037930f731 --- /dev/null +++ b/cli/exp_update_user_email_test.go @@ -0,0 +1,167 @@ +package cli_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/cli/clitest" + "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/serpent" +) + +func TestUpdateUserEmail(t *testing.T) { + t.Parallel() + + t.Run("CommandReachable", func(t *testing.T) { + t.Parallel() + + root := getRoot(t) + var found *serpent.Command + root.Walk(func(cmd *serpent.Command) { + if cmd.Name() == "update-user-email" { + found = cmd + } + }) + require.NotNil(t, found, "update-user-email command not found under exp") + require.True(t, found.Hidden, "command should be hidden") + }) + + t.Run("MissingOldEmail", func(t *testing.T) { + t.Parallel() + + inv, _ := clitest.New(t, "exp", "update-user-email", "--new-email", "new@example.com") + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "old-email") + }) + + t.Run("MissingNewEmail", func(t *testing.T) { + t.Parallel() + + inv, _ := clitest.New(t, "exp", "update-user-email", "--old-email", "old@example.com") + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "new-email") + }) + + t.Run("DeclinePrompt", func(t *testing.T) { + t.Parallel() + + // Track whether any API call is made. + apiCalled := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalled = true + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + + client := codersdk.New(must(url.Parse(srv.URL))) + inv, root := clitest.New(t, "exp", "update-user-email", + "--old-email", "old@example.com", + "--new-email", "new@example.com", + ) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("no\n") + + err := inv.Run() + require.ErrorIs(t, err, cliui.ErrCanceled) + require.False(t, apiCalled, "API should not be called when prompt is declined") + }) + + t.Run("AcceptPrompt", func(t *testing.T) { + t.Parallel() + + var gotBody codersdk.UpdateUserEmailRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/api/experimental/users/email", r.URL.Path) + err := json.NewDecoder(r.Body).Decode(&gotBody) + assert.NoError(t, err) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + + client := codersdk.New(must(url.Parse(srv.URL))) + inv, root := clitest.New(t, "exp", "update-user-email", + "--old-email", "old@example.com", + "--new-email", "new@example.com", + ) + clitest.SetupConfig(t, client, root) + inv.Stdin = strings.NewReader("yes\n") + + var outBuf bytes.Buffer + inv.Stdout = &outBuf + + require.NoError(t, inv.Run()) + + out := outBuf.String() + require.Contains(t, out, "old@example.com") + require.Contains(t, out, "new@example.com") + require.Contains(t, out, "sessions and API tokens") + require.Contains(t, out, "external identity provider") + require.Contains(t, out, "Updated user email from old@example.com to new@example.com.") + + require.Equal(t, "old@example.com", gotBody.OldEmail) + require.Equal(t, "new@example.com", gotBody.NewEmail) + }) + + t.Run("YesSkipsPrompt", func(t *testing.T) { + t.Parallel() + + var gotBody codersdk.UpdateUserEmailRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&gotBody) + assert.NoError(t, err) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + + client := codersdk.New(must(url.Parse(srv.URL))) + inv, root := clitest.New(t, "exp", "update-user-email", + "--old-email", "old@example.com", + "--new-email", "new@example.com", + "--yes", + ) + clitest.SetupConfig(t, client, root) + + var outBuf bytes.Buffer + inv.Stdout = &outBuf + + require.NoError(t, inv.Run()) + require.Contains(t, outBuf.String(), "Updated user email from old@example.com to new@example.com.") + require.Equal(t, "old@example.com", gotBody.OldEmail) + require.Equal(t, "new@example.com", gotBody.NewEmail) + }) + + t.Run("APIError", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"user not found"}`)) + })) + t.Cleanup(srv.Close) + + client := codersdk.New(must(url.Parse(srv.URL))) + inv, root := clitest.New(t, "exp", "update-user-email", + "--old-email", "old@example.com", + "--new-email", "new@example.com", + "--yes", + ) + clitest.SetupConfig(t, client, root) + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "update user email") + }) +} diff --git a/cli/root.go b/cli/root.go index 23055d93ee6..6fc7511b236 100644 --- a/cli/root.go +++ b/cli/root.go @@ -171,6 +171,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command { r.promptExample(), r.rptyCommand(), r.syncCommand(), + r.updateUserEmail(), } } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index b6fc628ca34..9e0bc143272 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -8326,6 +8326,22 @@ func (q *querier) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error return deleteQ(q.log, q.auth, q.db.GetUserByID, q.db.UpdateUserDeletedByID)(ctx, id) } +func (q *querier) UpdateUserEmail(ctx context.Context, arg database.UpdateUserEmailParams) (database.User, error) { + // Resolve the existing user by old email to obtain the RBAC object for + // authorization. The handler enforces the built-in owner role gate; this + // check adds defense-in-depth at the database layer. + existing, err := q.db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{ + Email: arg.OldEmail, + }) + if err != nil { + return database.User{}, err + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, existing); err != nil { + return database.User{}, err + } + return q.db.UpdateUserEmail(ctx, arg) +} + func (q *querier) UpdateUserGithubComUserID(ctx context.Context, arg database.UpdateUserGithubComUserIDParams) error { user, err := q.db.GetUserByID(ctx, arg.ID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 4f1b3c0e433..9cea5e8d4f9 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -3159,6 +3159,13 @@ func (s *MethodTestSuite) TestUser() { dbm.EXPECT().UpdateUserDeletedByID(gomock.Any(), u.ID).Return(nil).AnyTimes() check.Args(u.ID).Asserts(u, policy.ActionDelete).Returns() })) + s.Run("UpdateUserEmail", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + u := testutil.Fake(s.T(), faker, database.User{}) + arg := database.UpdateUserEmailParams{OldEmail: u.Email, NewEmail: "new@example.com", UpdatedAt: u.UpdatedAt} + dbm.EXPECT().GetUserByEmailOrUsername(gomock.Any(), database.GetUserByEmailOrUsernameParams{Email: u.Email}).Return(u, nil).AnyTimes() + dbm.EXPECT().UpdateUserEmail(gomock.Any(), arg).Return(u, nil).AnyTimes() + check.Args(arg).Asserts(u, policy.ActionUpdate).Returns(u) + })) s.Run("UpdateUserGithubComUserID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { u := testutil.Fake(s.T(), faker, database.User{}) arg := database.UpdateUserGithubComUserIDParams{ID: u.ID} diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 01fdeda2234..96fdffeb3f3 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -5801,6 +5801,14 @@ func (m queryMetricsStore) UpdateUserDeletedByID(ctx context.Context, id uuid.UU return r0 } +func (m queryMetricsStore) UpdateUserEmail(ctx context.Context, arg database.UpdateUserEmailParams) (database.User, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserEmail(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserEmail").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserEmail").Inc() + return r0, r1 +} + func (m queryMetricsStore) UpdateUserGithubComUserID(ctx context.Context, arg database.UpdateUserGithubComUserIDParams) error { start := time.Now() r0 := m.s.UpdateUserGithubComUserID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 8d3d8add3d1..622f9dc33a1 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -10962,6 +10962,21 @@ func (mr *MockStoreMockRecorder) UpdateUserDeletedByID(ctx, id any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserDeletedByID", reflect.TypeOf((*MockStore)(nil).UpdateUserDeletedByID), ctx, id) } +// UpdateUserEmail mocks base method. +func (m *MockStore) UpdateUserEmail(ctx context.Context, arg database.UpdateUserEmailParams) (database.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserEmail", ctx, arg) + ret0, _ := ret[0].(database.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserEmail indicates an expected call of UpdateUserEmail. +func (mr *MockStoreMockRecorder) UpdateUserEmail(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserEmail", reflect.TypeOf((*MockStore)(nil).UpdateUserEmail), ctx, arg) +} + // UpdateUserGithubComUserID mocks base method. func (m *MockStore) UpdateUserGithubComUserID(ctx context.Context, arg database.UpdateUserGithubComUserIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index ed6dfc1c789..e63f9826f7b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1587,6 +1587,7 @@ type sqlcQuerier interface { UpdateUserChatCustomPrompt(ctx context.Context, arg UpdateUserChatCustomPromptParams) (UserConfig, error) UpdateUserCodeDiffDisplayMode(ctx context.Context, arg UpdateUserCodeDiffDisplayModeParams) (string, error) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) error + UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) (User, error) UpdateUserGithubComUserID(ctx context.Context, arg UpdateUserGithubComUserIDParams) error UpdateUserHashedOneTimePasscode(ctx context.Context, arg UpdateUserHashedOneTimePasscodeParams) error UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b630a4278a2..510621ac5f0 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -19106,3 +19106,133 @@ func sessionFamilyCounts(t *testing.T, data json.RawMessage) map[codersdk.AppFam require.NoError(t, err) return counts } + +func TestUpdateUserEmail(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + ctx := context.Background() + + t.Run("UpdatesMatchingNonDeletedUser", func(t *testing.T) { + t.Parallel() + user := dbgen.User(t, db, database.User{}) + newEmail := "updated+" + user.Email + updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ + OldEmail: user.Email, + NewEmail: newEmail, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + require.Equal(t, newEmail, updated.Email) + require.Equal(t, user.ID, updated.ID) + }) + + t.Run("ClearsOTPColumns", func(t *testing.T) { + t.Parallel() + user := dbgen.User(t, db, database.User{}) + // Set OTP columns so we can verify they are cleared. + err := db.UpdateUserHashedOneTimePasscode(ctx, database.UpdateUserHashedOneTimePasscodeParams{ + ID: user.ID, + HashedOneTimePasscode: []byte("somehashvalue"), + OneTimePasscodeExpiresAt: sql.NullTime{Time: dbtime.Now().Add(time.Hour), Valid: true}, + }) + require.NoError(t, err) + + updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ + OldEmail: user.Email, + NewEmail: "cleared@example.com", + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + require.Nil(t, updated.HashedOneTimePasscode) + require.False(t, updated.OneTimePasscodeExpiresAt.Valid) + }) + + t.Run("MatchesCaseInsensitively", func(t *testing.T) { + t.Parallel() + origEmail := "CaseSensitive" + testutil.GetRandomName(t) + "@example.com" + user := dbgen.User(t, db, database.User{Email: origEmail}) + updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ + OldEmail: strings.ToLower(origEmail), + NewEmail: "newcase" + testutil.GetRandomName(t) + "@example.com", + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + require.Equal(t, user.ID, updated.ID, "should match the same user case-insensitively") + }) + + t.Run("DoesNotMatchDeletedUsers", func(t *testing.T) { + t.Parallel() + user := dbgen.User(t, db, database.User{}) + err := db.UpdateUserDeletedByID(ctx, user.ID) + require.NoError(t, err) + + _, err = db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ + OldEmail: user.Email, + NewEmail: "shouldfail@example.com", + UpdatedAt: dbtime.Now(), + }) + require.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("ReturnsNoRowsForMissingOldEmail", func(t *testing.T) { + t.Parallel() + _, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ + OldEmail: "doesnotexist" + testutil.GetRandomName(t) + "@example.com", + NewEmail: "new@example.com", + UpdatedAt: dbtime.Now(), + }) + require.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("PreservesNewEmailCasing", func(t *testing.T) { + t.Parallel() + user := dbgen.User(t, db, database.User{}) + mixedCase := "Mixed.Case." + testutil.GetRandomName(t) + "@Example.COM" + updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ + OldEmail: user.Email, + NewEmail: mixedCase, + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + require.Equal(t, mixedCase, updated.Email, "new email casing must be preserved exactly") + }) + + t.Run("RejectsUniqueEmailCollision", func(t *testing.T) { + t.Parallel() + user1 := dbgen.User(t, db, database.User{}) + user2 := dbgen.User(t, db, database.User{}) + // Try to set user1's email to a case-variant of user2's email. + _, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ + OldEmail: user1.Email, + NewEmail: strings.ToUpper(user2.Email), + UpdatedAt: dbtime.Now(), + }) + require.True(t, database.IsUniqueViolation(err, database.UniqueUsersEmailLowerIndex), + "expected unique_violation on users_email_lower_idx, got: %v", err) + }) + + t.Run("DoesNotAffectOtherUsers", func(t *testing.T) { + t.Parallel() + user1 := dbgen.User(t, db, database.User{}) + user2 := dbgen.User(t, db, database.User{}) + user2EmailBefore := user2.Email + + _, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ + OldEmail: user1.Email, + NewEmail: "onlyone" + testutil.GetRandomName(t) + "@example.com", + UpdatedAt: dbtime.Now(), + }) + require.NoError(t, err) + + user2After, err := db.GetUserByID(ctx, user2.ID) + require.NoError(t, err) + require.Equal(t, user2EmailBefore, user2After.Email, "other user email must not change") + }) +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 36b4696588c..fd710b3194a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -31815,6 +31815,54 @@ func (q *sqlQuerier) UpdateUserDeletedByID(ctx context.Context, id uuid.UUID) er return err } +const updateUserEmail = `-- name: UpdateUserEmail :one +UPDATE + users +SET + email = $1, + updated_at = $2, + hashed_one_time_passcode = NULL, + one_time_passcode_expires_at = NULL +WHERE + LOWER(email) = LOWER($3) + AND deleted = false +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at, is_system, is_service_account, chat_spend_limit_micros +` + +type UpdateUserEmailParams struct { + NewEmail string `db:"new_email" json:"new_email"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OldEmail string `db:"old_email" json:"old_email"` +} + +func (q *sqlQuerier) UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUserEmail, arg.NewEmail, arg.UpdatedAt, arg.OldEmail) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.RBACRoles, + &i.LoginType, + &i.AvatarURL, + &i.Deleted, + &i.LastSeenAt, + &i.QuietHoursSchedule, + &i.Name, + &i.GithubComUserID, + &i.HashedOneTimePasscode, + &i.OneTimePasscodeExpiresAt, + &i.IsSystem, + &i.IsServiceAccount, + &i.ChatSpendLimitMicros, + ) + return i, err +} + const updateUserGithubComUserID = `-- name: UpdateUserGithubComUserID :exec UPDATE users diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 3c4875d766d..e04d54e0c87 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -396,6 +396,19 @@ SET WHERE id = $1; +-- name: UpdateUserEmail :one +UPDATE + users +SET + email = @new_email, + updated_at = @updated_at, + hashed_one_time_passcode = NULL, + one_time_passcode_expires_at = NULL +WHERE + LOWER(email) = LOWER(@old_email) + AND deleted = false +RETURNING *; + -- name: UpdateUserDeletedByID :exec UPDATE users diff --git a/codersdk/users.go b/codersdk/users.go index c784c13e8c9..ab0d9edfa25 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -238,6 +238,13 @@ type UpdateUserProfileRequest struct { AvatarURL string `json:"avatar_url" format:"uri"` } +// UpdateUserEmailRequest changes a user's email by matching their current +// email address. This API is experimental and may change without notice. +type UpdateUserEmailRequest struct { + OldEmail string `json:"old_email" validate:"required,email" format:"email"` + NewEmail string `json:"new_email" validate:"required,email" format:"email"` +} + type ValidateUserPasswordRequest struct { Password string `json:"password" validate:"required"` } @@ -608,6 +615,20 @@ func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateU return resp, ReadBodyAsJSON(res, &resp) } +// UpdateUserEmail changes a user's email address. This API is experimental and +// may change without notice. +func (c *Client) UpdateUserEmail(ctx context.Context, req UpdateUserEmailRequest) error { + res, err := c.Request(ctx, http.MethodPut, "/api/experimental/users/email", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // ValidateUserPassword validates the complexity of a user password and that it is secured enough. func (c *Client) ValidateUserPassword(ctx context.Context, req ValidateUserPasswordRequest) (ValidateUserPasswordResponse, error) { res, err := c.Request(ctx, http.MethodPost, "/api/v2/users/validate-password", req) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 01c3c665030..5c821133b6b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -10018,6 +10018,16 @@ export interface UpdateUserChatPersonalModelOverrideRequest { readonly reasoning_effort?: string; } +// From codersdk/users.go +/** + * UpdateUserEmailRequest changes a user's email by matching their current + * email address. This API is experimental and may change without notice. + */ +export interface UpdateUserEmailRequest { + readonly old_email: string; + readonly new_email: string; +} + // From codersdk/notifications.go export interface UpdateUserNotificationPreferences { readonly template_disabled_map: Record; From a7a9cd0f08fc8f7721e182ad140b5f07f0a2f91a Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 11 Sep 2026 22:59:11 +0000 Subject: [PATCH 2/3] feat(coderd): add break-glass email update --- cli/exp_update_user_email.go | 16 +-- coderd/apidoc/docs.go | 53 +++++++++ coderd/apidoc/swagger.json | 46 ++++++++ coderd/coderd.go | 5 + coderd/users.go | 152 ++++++++++++++++++++++++ coderd/users_test.go | 213 ++++++++++++++++++++++++++++++++++ docs/reference/api/schemas.md | 16 +++ 7 files changed, 493 insertions(+), 8 deletions(-) diff --git a/cli/exp_update_user_email.go b/cli/exp_update_user_email.go index c31558dfb73..87de3223ae5 100644 --- a/cli/exp_update_user_email.go +++ b/cli/exp_update_user_email.go @@ -37,10 +37,15 @@ func (r *RootCmd) updateUserEmail() *serpent.Command { }, Handler: func(inv *serpent.Invocation) error { if oldEmail == "" { - return xerrors.Errorf("--old-email is required") + return xerrors.Errorf("--old-email must not be blank") } if newEmail == "" { - return xerrors.Errorf("--new-email is required") + return xerrors.Errorf("--new-email must not be blank") + } + + client, err := r.InitClient(inv) + if err != nil { + return err } _, _ = fmt.Fprintf(inv.Stdout, @@ -50,7 +55,7 @@ func (r *RootCmd) updateUserEmail() *serpent.Command { oldEmail, newEmail, ) - _, err := cliui.Prompt(inv, cliui.PromptOptions{ + _, err = cliui.Prompt(inv, cliui.PromptOptions{ Text: "Confirm email update?", IsConfirm: true, Default: cliui.ConfirmNo, @@ -59,11 +64,6 @@ func (r *RootCmd) updateUserEmail() *serpent.Command { return err } - client, err := r.InitClient(inv) - if err != nil { - return err - } - err = client.UpdateUserEmail(inv.Context(), codersdk.UpdateUserEmailRequest{ OldEmail: oldEmail, NewEmail: newEmail, diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 72bf693deb0..afc49eb07cc 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -246,6 +246,42 @@ const docTemplate = `{ } } }, + "/api/experimental/users/email": { + "put": { + "consumes": [ + "application/json" + ], + "tags": [ + "Users" + ], + "summary": "Update user email", + "operationId": "update-user-email-experimental", + "parameters": [ + { + "description": "Update email request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserEmailRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/users/{user}/skills": { "get": { "produces": [ @@ -29695,6 +29731,23 @@ const docTemplate = `{ } } }, + "codersdk.UpdateUserEmailRequest": { + "type": "object", + "required": [ + "new_email", + "old_email" + ], + "properties": { + "new_email": { + "type": "string", + "format": "email" + }, + "old_email": { + "type": "string", + "format": "email" + } + } + }, "codersdk.UpdateUserNotificationPreferences": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 58f7ca844a1..2c527b2d79a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -211,6 +211,38 @@ } } }, + "/api/experimental/users/email": { + "put": { + "consumes": ["application/json"], + "tags": ["Users"], + "summary": "Update user email", + "operationId": "update-user-email-experimental", + "parameters": [ + { + "description": "Update email request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserEmailRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + } + }, + "security": [ + { + "CoderSessionToken": [] + } + ], + "x-apidocgen": { + "skip": true + } + } + }, "/api/experimental/users/{user}/skills": { "get": { "produces": ["application/json"], @@ -27325,6 +27357,20 @@ } } }, + "codersdk.UpdateUserEmailRequest": { + "type": "object", + "required": ["new_email", "old_email"], + "properties": { + "new_email": { + "type": "string", + "format": "email" + }, + "old_email": { + "type": "string", + "format": "email" + } + } + }, "codersdk.UpdateUserNotificationPreferences": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 1b391185eb3..c346f33646f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1327,6 +1327,11 @@ func New(options *Options) *API { httpmw.ReportCLITelemetry(api.Logger, options.Telemetry), ) + r.Route("/users/email", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Put("/", api.putUserEmailExperimental) + }) + // NOTE(DanielleMaywood): r.Route("/users/{user}/skills", func(r chi.Router) { r.Use( diff --git a/coderd/users.go b/coderd/users.go index 40df48ef184..c97c541a13a 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -7,8 +7,10 @@ import ( "fmt" "net/http" "slices" + "strings" "github.com/go-chi/chi/v5" + "github.com/go-playground/validator/v10" "github.com/google/uuid" "golang.org/x/xerrors" @@ -866,6 +868,156 @@ func (*API) userLoginType(rw http.ResponseWriter, r *http.Request) { }) } +type updateUserEmailAuditFields struct { + OldEmail string `json:"old_email,omitempty"` + NewEmail string `json:"new_email,omitempty"` +} + +// putUserEmailExperimental updates a user's email address and revokes all of +// their Coder credentials. +// +// @Summary Update user email +// @ID update-user-email-experimental +// @Security CoderSessionToken +// @Accept json +// @Tags Users +// @Param request body codersdk.UpdateUserEmailRequest true "Update email request" +// @Success 204 +// @Router /api/experimental/users/email [put] +// @x-apidocgen {"skip": true} +func (api *API) putUserEmailExperimental(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + apiKey = httpmw.APIKey(r) + auditor = *api.Auditor.Load() + auditFields = &updateUserEmailAuditFields{} + aReq, done = audit.InitRequest[database.User](rw, &audit.RequestParams{ + Audit: auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + AdditionalFields: auditFields, + }) + ) + defer done() + + actor, err := api.Database.GetUserByID(ctx, apiKey.UserID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get acting user.", + Detail: err.Error(), + }) + return + } + aReq.Old = actor + + if !slices.Contains(actor.RBACRoles, rbac.RoleOwner().String()) { + httpapi.ResourceNotFound(rw) + return + } + + var rawReq struct { + OldEmail string `json:"old_email"` + NewEmail string `json:"new_email"` + } + if !httpapi.Read(ctx, rw, r, &rawReq) { + return + } + auditFields.OldEmail = rawReq.OldEmail + auditFields.NewEmail = rawReq.NewEmail + + req := codersdk.UpdateUserEmailRequest{ + OldEmail: rawReq.OldEmail, + NewEmail: rawReq.NewEmail, + } + if err := httpapi.Validate.Struct(req); err != nil { + var validationErrors validator.ValidationErrors + if errors.As(err, &validationErrors) { + apiErrors := make([]codersdk.ValidationError, 0, len(validationErrors)) + for _, validationError := range validationErrors { + apiErrors = append(apiErrors, codersdk.ValidationError{ + Field: validationError.Field(), + Detail: fmt.Sprintf("Validation failed for tag %q with value: \"%v\"", validationError.Tag(), validationError.Value()), + }) + } + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Validation failed.", + Validations: apiErrors, + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error validating request body payload.", + Detail: err.Error(), + }) + return + } + + var updated database.User + err = api.Database.InTx(func(tx database.Store) error { + target, err := tx.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{ + Email: req.OldEmail, + }) + if err != nil { + return err + } + aReq.Old = target + + if target.ID == actor.ID { + return errUpdateUserEmailSelf + } + if strings.EqualFold(req.OldEmail, req.NewEmail) { + return errUpdateUserEmailUnchanged + } + + updated, err = tx.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ + OldEmail: req.OldEmail, + NewEmail: req.NewEmail, + UpdatedAt: dbtime.Now(), + }) + if err != nil { + return err + } + + //nolint:gocritic // This break-glass operation must revoke all API keys + // owned by the target user, not only keys visible to the acting owner. + return tx.DeleteAPIKeysByUserID(dbauthz.AsAPIKeyRevoker(ctx, target.ID), target.ID) + }, nil) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + httpapi.ResourceNotFound(rw) + case errors.Is(err, errUpdateUserEmailSelf): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "You cannot update your own email address.", + }) + case errors.Is(err, errUpdateUserEmailUnchanged): + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "The old and new email addresses must differ beyond letter casing.", + }) + case database.IsUniqueViolation(err, database.UniqueIndexUsersEmail), + database.IsUniqueViolation(err, database.UniqueUsersEmailLowerIndex): + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: "A user with the new email address already exists.", + }) + default: + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update user email.", + Detail: err.Error(), + }) + } + return + } + + aReq.New = updated + rw.WriteHeader(http.StatusNoContent) +} + +var ( + errUpdateUserEmailSelf = xerrors.New("cannot update own email") + errUpdateUserEmailUnchanged = xerrors.New("old and new email are equal") +) + // @Summary Update user profile // @ID update-user-profile // @Security CoderSessionToken diff --git a/coderd/users_test.go b/coderd/users_test.go index 9c781850d5b..ae25a7a4ba4 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -2,6 +2,8 @@ package coderd_test import ( "context" + "database/sql" + "encoding/json" "fmt" "net/http" "slices" @@ -1212,6 +1214,217 @@ func TestNotifyCreatedUser(t *testing.T) { }) } +func TestUpdateUserEmailExperimental(t *testing.T) { + t.Parallel() + + t.Run("Success", func(t *testing.T) { + t.Parallel() + + auditor := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map { + oldUser := old.(database.User) + newUser := newVal.(database.User) + return audit.Map{ + "email": audit.OldNew{Old: oldUser.Email, New: newUser.Email}, + } + }) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor}) + owner := coderdtest.CreateFirstUser(t, client) + memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) + + oldEmail := member.Email + newEmail := fmt.Sprintf("Updated-%s@example.com", uuid.NewString()) + oldToken := memberClient.SessionToken() + extraToken, err := client.CreateToken(ctx, member.ID.String(), codersdk.CreateTokenRequest{}) + require.NoError(t, err) + err = db.UpdateUserHashedOneTimePasscode(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedOneTimePasscodeParams{ + ID: member.ID, + HashedOneTimePasscode: []byte("hashed-passcode"), + OneTimePasscodeExpiresAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true}, + }) + require.NoError(t, err) + auditor.ResetLogs() + + err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: oldEmail, + NewEmail: newEmail, + }) + require.NoError(t, err) + + updated, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), member.ID) + require.NoError(t, err) + require.Equal(t, newEmail, updated.Email) + require.Nil(t, updated.HashedOneTimePasscode) + require.False(t, updated.OneTimePasscodeExpiresAt.Valid) + + for _, token := range []string{oldToken, extraToken.Key} { + revokedClient := codersdk.New(client.URL, codersdk.WithSessionToken(token)) + _, err := revokedClient.User(ctx, codersdk.Me) + require.Error(t, err) + } + _, err = client.User(ctx, codersdk.Me) + require.NoError(t, err) + + logs := auditor.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, owner.UserID, logs[0].UserID) + require.Equal(t, member.ID, logs[0].ResourceID) + require.Equal(t, database.AuditActionWrite, logs[0].Action) + require.Equal(t, int32(http.StatusNoContent), logs[0].StatusCode) + var fields map[string]string + require.NoError(t, json.Unmarshal(logs[0].AdditionalFields, &fields)) + require.Equal(t, oldEmail, fields["old_email"]) + require.Equal(t, newEmail, fields["new_email"]) + }) + + t.Run("TargetVariants", func(t *testing.T) { + t.Parallel() + + client, db := coderdtest.NewWithDatabase(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + tests := []struct { + name string + loginType database.LoginType + role rbac.RoleIdentifier + status database.UserStatus + }{ + {name: "OIDC", loginType: database.LoginTypeOIDC}, + {name: "GitHub", loginType: database.LoginTypeGithub}, + {name: "Dormant", status: database.UserStatusDormant}, + {name: "Suspended", status: database.UserStatusSuspended}, + {name: "Owner", role: rbac.RoleOwner()}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + roles := []rbac.RoleIdentifier{} + if test.role.Name != "" { + roles = append(roles, test.role) + } + _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, roles...) + if test.loginType != "" { + updated, err := db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ + UserID: target.ID, + NewLoginType: test.loginType, + }) + require.NoError(t, err) + target.LoginType = codersdk.LoginType(updated.LoginType) + } + if test.status != "" { + _, err := db.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ + ID: target.ID, + Status: test.status, + UpdatedAt: dbtime.Now(), + UserIsSeen: false, + }) + require.NoError(t, err) + } + + newEmail := fmt.Sprintf("variant-%s@example.com", uuid.NewString()) + err := client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: target.Email, + NewEmail: newEmail, + }) + require.NoError(t, err) + updated, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), target.ID) + require.NoError(t, err) + require.Equal(t, newEmail, updated.Email) + }) + } + }) + + t.Run("OwnerOnly", func(t *testing.T) { + t.Parallel() + + auditor := audit.NewMock() + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor}) + owner := coderdtest.CreateFirstUser(t, client) + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleUserAdmin()) + _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + auditor.ResetLogs() + ctx := testutil.Context(t, testutil.WaitLong) + + err := userAdminClient.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: target.Email, + NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + unchanged, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), target.ID) + require.NoError(t, err) + require.Equal(t, target.Email, unchanged.Email) + + logs := auditor.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, int32(http.StatusNotFound), logs[0].StatusCode) + require.JSONEq(t, `{}`, string(logs[0].AdditionalFields)) + }) + + t.Run("RejectsSelf", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + ownerResp := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + owner, err := client.User(ctx, ownerResp.UserID.String()) + require.NoError(t, err) + err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: owner.Email, + NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("Validation", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) + + for _, req := range []codersdk.UpdateUserEmailRequest{ + {OldEmail: " " + target.Email, NewEmail: "new@example.com"}, + {OldEmail: target.Email, NewEmail: target.Email}, + {OldEmail: strings.ToUpper(target.Email), NewEmail: target.Email}, + } { + err := client.UpdateUserEmail(ctx, req) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + } + }) + + t.Run("NotFoundAndConflict", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, existing := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ctx := testutil.Context(t, testutil.WaitLong) + + err := client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: fmt.Sprintf("missing-%s@example.com", uuid.NewString()), + NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + + err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: target.Email, + NewEmail: strings.ToUpper(existing.Email), + }) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + }) +} + func TestUpdateUserProfile(t *testing.T) { t.Parallel() t.Run("UserNotFound", func(t *testing.T) { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ce95802e3e0..1d6e0de1a28 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -15869,6 +15869,22 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `model_config_id` | string | false | | | | `reasoning_effort` | string | false | | | +## codersdk.UpdateUserEmailRequest + +```json +{ + "new_email": "user@example.com", + "old_email": "user@example.com" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|-------------|--------|----------|--------------|-------------| +| `new_email` | string | true | | | +| `old_email` | string | true | | | + ## codersdk.UpdateUserNotificationPreferences ```json From 0b51b60a01eb2828a1c9dd00be5508e0593ca1aa Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Sun, 13 Sep 2026 19:37:11 +0000 Subject: [PATCH 3/3] test: streamline user email update coverage --- cli/exp_update_user_email_test.go | 74 ++++--- coderd/database/querier_test.go | 69 +------ coderd/users_test.go | 327 +++++++++++++----------------- 3 files changed, 191 insertions(+), 279 deletions(-) diff --git a/cli/exp_update_user_email_test.go b/cli/exp_update_user_email_test.go index f037930f731..01ff4d00f18 100644 --- a/cli/exp_update_user_email_test.go +++ b/cli/exp_update_user_email_test.go @@ -14,7 +14,9 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" "github.com/coder/serpent" ) @@ -56,10 +58,10 @@ func TestUpdateUserEmail(t *testing.T) { t.Run("DeclinePrompt", func(t *testing.T) { t.Parallel() - // Track whether any API call is made. - apiCalled := false + // Use a channel closed by the handler to detect whether the API is called. + apiCalled := make(chan struct{}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - apiCalled = true + close(apiCalled) w.WriteHeader(http.StatusNoContent) })) t.Cleanup(srv.Close) @@ -72,29 +74,44 @@ func TestUpdateUserEmail(t *testing.T) { clitest.SetupConfig(t, client, root) inv.Stdin = strings.NewReader("no\n") - err := inv.Run() - require.ErrorIs(t, err, cliui.ErrCanceled) - require.False(t, apiCalled, "API should not be called when prompt is declined") + ctx := testutil.Context(t, testutil.WaitShort) + done := make(chan struct{}) + var runErr error + go func() { + defer close(done) + runErr = inv.Run() + }() + + testutil.TryReceive(ctx, t, done) + require.ErrorIs(t, runErr, cliui.ErrCanceled) + + // Verify the API was not called after the command returned. + select { + case <-apiCalled: + t.Fatal("API should not be called when prompt is declined") + default: + } }) + // AcceptPrompt runs a full end-to-end test against a real Coder server: it + // creates a second user, invokes the CLI as an admin, confirms the prompt, + // and asserts both the command output and the actual stored email. t.Run("AcceptPrompt", func(t *testing.T) { t.Parallel() - var gotBody codersdk.UpdateUserEmailRequest - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPut, r.Method) - assert.Equal(t, "/api/experimental/users/email", r.URL.Path) - err := json.NewDecoder(r.Body).Decode(&gotBody) - assert.NoError(t, err) - w.WriteHeader(http.StatusNoContent) - })) - t.Cleanup(srv.Close) + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + _, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + ctx := testutil.Context(t, testutil.WaitShort) + + oldEmail := member.Email + newEmail := "updated-" + oldEmail - client := codersdk.New(must(url.Parse(srv.URL))) inv, root := clitest.New(t, "exp", "update-user-email", - "--old-email", "old@example.com", - "--new-email", "new@example.com", + "--old-email", oldEmail, + "--new-email", newEmail, ) + //nolint:gocritic // This break-glass command is restricted to deployment owners. clitest.SetupConfig(t, client, root) inv.Stdin = strings.NewReader("yes\n") @@ -104,14 +121,16 @@ func TestUpdateUserEmail(t *testing.T) { require.NoError(t, inv.Run()) out := outBuf.String() - require.Contains(t, out, "old@example.com") - require.Contains(t, out, "new@example.com") + require.Contains(t, out, oldEmail) + require.Contains(t, out, newEmail) require.Contains(t, out, "sessions and API tokens") require.Contains(t, out, "external identity provider") - require.Contains(t, out, "Updated user email from old@example.com to new@example.com.") + require.Contains(t, out, "Updated user email from "+oldEmail+" to "+newEmail+".") - require.Equal(t, "old@example.com", gotBody.OldEmail) - require.Equal(t, "new@example.com", gotBody.NewEmail) + // Verify the email was actually persisted. + updated, err := client.User(ctx, member.ID.String()) + require.NoError(t, err) + require.Equal(t, newEmail, updated.Email) }) t.Run("YesSkipsPrompt", func(t *testing.T) { @@ -147,8 +166,8 @@ func TestUpdateUserEmail(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message":"user not found"}`)) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"internal server error"}`)) })) t.Cleanup(srv.Close) @@ -163,5 +182,10 @@ func TestUpdateUserEmail(t *testing.T) { err := inv.Run() require.Error(t, err) require.Contains(t, err.Error(), "update user email") + + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusInternalServerError, sdkErr.StatusCode()) + require.Contains(t, sdkErr.Message, "internal server error") }) } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index 510621ac5f0..23a9c0c2f36 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -19119,41 +19119,7 @@ func TestUpdateUserEmail(t *testing.T) { db := database.New(sqlDB) ctx := context.Background() - t.Run("UpdatesMatchingNonDeletedUser", func(t *testing.T) { - t.Parallel() - user := dbgen.User(t, db, database.User{}) - newEmail := "updated+" + user.Email - updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ - OldEmail: user.Email, - NewEmail: newEmail, - UpdatedAt: dbtime.Now(), - }) - require.NoError(t, err) - require.Equal(t, newEmail, updated.Email) - require.Equal(t, user.ID, updated.ID) - }) - - t.Run("ClearsOTPColumns", func(t *testing.T) { - t.Parallel() - user := dbgen.User(t, db, database.User{}) - // Set OTP columns so we can verify they are cleared. - err := db.UpdateUserHashedOneTimePasscode(ctx, database.UpdateUserHashedOneTimePasscodeParams{ - ID: user.ID, - HashedOneTimePasscode: []byte("somehashvalue"), - OneTimePasscodeExpiresAt: sql.NullTime{Time: dbtime.Now().Add(time.Hour), Valid: true}, - }) - require.NoError(t, err) - - updated, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ - OldEmail: user.Email, - NewEmail: "cleared@example.com", - UpdatedAt: dbtime.Now(), - }) - require.NoError(t, err) - require.Nil(t, updated.HashedOneTimePasscode) - require.False(t, updated.OneTimePasscodeExpiresAt.Valid) - }) - + // SQL-specific: the WHERE clause matches old_email case-insensitively. t.Run("MatchesCaseInsensitively", func(t *testing.T) { t.Parallel() origEmail := "CaseSensitive" + testutil.GetRandomName(t) + "@example.com" @@ -19167,6 +19133,7 @@ func TestUpdateUserEmail(t *testing.T) { require.Equal(t, user.ID, updated.ID, "should match the same user case-insensitively") }) + // SQL-specific: the WHERE clause excludes soft-deleted users. t.Run("DoesNotMatchDeletedUsers", func(t *testing.T) { t.Parallel() user := dbgen.User(t, db, database.User{}) @@ -19181,16 +19148,7 @@ func TestUpdateUserEmail(t *testing.T) { require.ErrorIs(t, err, sql.ErrNoRows) }) - t.Run("ReturnsNoRowsForMissingOldEmail", func(t *testing.T) { - t.Parallel() - _, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ - OldEmail: "doesnotexist" + testutil.GetRandomName(t) + "@example.com", - NewEmail: "new@example.com", - UpdatedAt: dbtime.Now(), - }) - require.ErrorIs(t, err, sql.ErrNoRows) - }) - + // SQL-specific: new_email is stored verbatim (no lower-casing on write). t.Run("PreservesNewEmailCasing", func(t *testing.T) { t.Parallel() user := dbgen.User(t, db, database.User{}) @@ -19204,11 +19162,12 @@ func TestUpdateUserEmail(t *testing.T) { require.Equal(t, mixedCase, updated.Email, "new email casing must be preserved exactly") }) + // SQL-specific: the unique index name is users_email_lower_idx and the + // constraint fires on a case-variant collision. t.Run("RejectsUniqueEmailCollision", func(t *testing.T) { t.Parallel() user1 := dbgen.User(t, db, database.User{}) user2 := dbgen.User(t, db, database.User{}) - // Try to set user1's email to a case-variant of user2's email. _, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ OldEmail: user1.Email, NewEmail: strings.ToUpper(user2.Email), @@ -19217,22 +19176,4 @@ func TestUpdateUserEmail(t *testing.T) { require.True(t, database.IsUniqueViolation(err, database.UniqueUsersEmailLowerIndex), "expected unique_violation on users_email_lower_idx, got: %v", err) }) - - t.Run("DoesNotAffectOtherUsers", func(t *testing.T) { - t.Parallel() - user1 := dbgen.User(t, db, database.User{}) - user2 := dbgen.User(t, db, database.User{}) - user2EmailBefore := user2.Email - - _, err := db.UpdateUserEmail(ctx, database.UpdateUserEmailParams{ - OldEmail: user1.Email, - NewEmail: "onlyone" + testutil.GetRandomName(t) + "@example.com", - UpdatedAt: dbtime.Now(), - }) - require.NoError(t, err) - - user2After, err := db.GetUserByID(ctx, user2.ID) - require.NoError(t, err) - require.Equal(t, user2EmailBefore, user2After.Email, "other user email must not change") - }) } diff --git a/coderd/users_test.go b/coderd/users_test.go index ae25a7a4ba4..a9bf388bc0d 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -1217,212 +1217,159 @@ func TestNotifyCreatedUser(t *testing.T) { func TestUpdateUserEmailExperimental(t *testing.T) { t.Parallel() - t.Run("Success", func(t *testing.T) { - t.Parallel() - - auditor := audit.NewMockWithDiffFn(func(old, newVal any) audit.Map { - oldUser := old.(database.User) - newUser := newVal.(database.User) - return audit.Map{ - "email": audit.OldNew{Old: oldUser.Email, New: newUser.Email}, - } - }) - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor}) - owner := coderdtest.CreateFirstUser(t, client) - memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - ctx := testutil.Context(t, testutil.WaitLong) - - oldEmail := member.Email - newEmail := fmt.Sprintf("Updated-%s@example.com", uuid.NewString()) - oldToken := memberClient.SessionToken() - extraToken, err := client.CreateToken(ctx, member.ID.String(), codersdk.CreateTokenRequest{}) - require.NoError(t, err) - err = db.UpdateUserHashedOneTimePasscode(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedOneTimePasscodeParams{ - ID: member.ID, - HashedOneTimePasscode: []byte("hashed-passcode"), - OneTimePasscodeExpiresAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true}, - }) - require.NoError(t, err) - auditor.ResetLogs() - - err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ - OldEmail: oldEmail, - NewEmail: newEmail, - }) - require.NoError(t, err) - - updated, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), member.ID) - require.NoError(t, err) - require.Equal(t, newEmail, updated.Email) - require.Nil(t, updated.HashedOneTimePasscode) - require.False(t, updated.OneTimePasscodeExpiresAt.Valid) + // Share one server because these assertions do not require isolated state. + auditor := audit.NewMock() + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor}) + owner := coderdtest.CreateFirstUser(t, client) + ctx := testutil.Context(t, testutil.WaitLong) + + // Only built-in deployment owners may use the endpoint. + userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleUserAdmin()) + _, authTarget := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + auditor.ResetLogs() + + err := userAdminClient.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: authTarget.Email, + NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + unchanged, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), authTarget.ID) + require.NoError(t, err) + require.Equal(t, authTarget.Email, unchanged.Email) + logs := auditor.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, int32(http.StatusNotFound), logs[0].StatusCode) + require.JSONEq(t, `{}`, string(logs[0].AdditionalFields)) + + // Owners cannot update their own email. + ownerUser, err := client.User(ctx, owner.UserID.String()) + require.NoError(t, err) + err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: ownerUser.Email, + NewEmail: fmt.Sprintf("self-%s@example.com", uuid.NewString()), + }) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + + // Invalid inputs do not change user state. + _, validTarget := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + for _, req := range []codersdk.UpdateUserEmailRequest{ + {OldEmail: " " + validTarget.Email, NewEmail: "new@example.com"}, + {OldEmail: validTarget.Email, NewEmail: validTarget.Email}, + {OldEmail: strings.ToUpper(validTarget.Email), NewEmail: validTarget.Email}, + } { + err := client.UpdateUserEmail(ctx, req) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + } - for _, token := range []string{oldToken, extraToken.Key} { - revokedClient := codersdk.New(client.URL, codersdk.WithSessionToken(token)) - _, err := revokedClient.User(ctx, codersdk.Me) - require.Error(t, err) - } - _, err = client.User(ctx, codersdk.Me) - require.NoError(t, err) + // The old email must exist and the new email must be available. + _, conflictTarget := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + _, existingUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - logs := auditor.AuditLogs() - require.Len(t, logs, 1) - require.Equal(t, owner.UserID, logs[0].UserID) - require.Equal(t, member.ID, logs[0].ResourceID) - require.Equal(t, database.AuditActionWrite, logs[0].Action) - require.Equal(t, int32(http.StatusNoContent), logs[0].StatusCode) - var fields map[string]string - require.NoError(t, json.Unmarshal(logs[0].AdditionalFields, &fields)) - require.Equal(t, oldEmail, fields["old_email"]) - require.Equal(t, newEmail, fields["new_email"]) + err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: fmt.Sprintf("missing-%s@example.com", uuid.NewString()), + NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()), }) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) - t.Run("TargetVariants", func(t *testing.T) { - t.Parallel() - - client, db := coderdtest.NewWithDatabase(t, nil) - owner := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) + err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: conflictTarget.Email, + NewEmail: strings.ToUpper(existingUser.Email), + }) + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) - tests := []struct { - name string - loginType database.LoginType - role rbac.RoleIdentifier - status database.UserStatus - }{ - {name: "OIDC", loginType: database.LoginTypeOIDC}, - {name: "GitHub", loginType: database.LoginTypeGithub}, - {name: "Dormant", status: database.UserStatusDormant}, - {name: "Suspended", status: database.UserStatusSuspended}, - {name: "Owner", role: rbac.RoleOwner()}, + // Login type, status, and the target's owner role do not prevent updates. + for _, tc := range []struct { + name string + loginType database.LoginType + role rbac.RoleIdentifier + status database.UserStatus + }{ + {name: "OIDC", loginType: database.LoginTypeOIDC}, + {name: "GitHub", loginType: database.LoginTypeGithub}, + {name: "Dormant", status: database.UserStatusDormant}, + {name: "Suspended", status: database.UserStatusSuspended}, + {name: "Owner", role: rbac.RoleOwner()}, + } { + roles := []rbac.RoleIdentifier{} + if tc.role.Name != "" { + roles = append(roles, tc.role) } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - roles := []rbac.RoleIdentifier{} - if test.role.Name != "" { - roles = append(roles, test.role) - } - _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, roles...) - if test.loginType != "" { - updated, err := db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ - UserID: target.ID, - NewLoginType: test.loginType, - }) - require.NoError(t, err) - target.LoginType = codersdk.LoginType(updated.LoginType) - } - if test.status != "" { - _, err := db.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ - ID: target.ID, - Status: test.status, - UpdatedAt: dbtime.Now(), - UserIsSeen: false, - }) - require.NoError(t, err) - } - - newEmail := fmt.Sprintf("variant-%s@example.com", uuid.NewString()) - err := client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ - OldEmail: target.Email, - NewEmail: newEmail, - }) - require.NoError(t, err) - updated, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), target.ID) - require.NoError(t, err) - require.Equal(t, newEmail, updated.Email) + _, variant := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, roles...) + if tc.loginType != "" { + variantUpdated, err := db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{ + UserID: variant.ID, + NewLoginType: tc.loginType, }) + require.NoError(t, err) + variant.LoginType = codersdk.LoginType(variantUpdated.LoginType) } - }) - - t.Run("OwnerOnly", func(t *testing.T) { - t.Parallel() - - auditor := audit.NewMock() - client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor}) - owner := coderdtest.CreateFirstUser(t, client) - userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleUserAdmin()) - _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - auditor.ResetLogs() - ctx := testutil.Context(t, testutil.WaitLong) - - err := userAdminClient.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ - OldEmail: target.Email, - NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()), + if tc.status != "" { + _, err := db.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{ + ID: variant.ID, + Status: tc.status, + UpdatedAt: dbtime.Now(), + UserIsSeen: false, + }) + require.NoError(t, err) + } + newVariantEmail := fmt.Sprintf("variant-%s@example.com", uuid.NewString()) + err := client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: variant.Email, + NewEmail: newVariantEmail, }) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) - unchanged, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), target.ID) - require.NoError(t, err) - require.Equal(t, target.Email, unchanged.Email) - - logs := auditor.AuditLogs() - require.Len(t, logs, 1) - require.Equal(t, int32(http.StatusNotFound), logs[0].StatusCode) - require.JSONEq(t, `{}`, string(logs[0].AdditionalFields)) - }) - - t.Run("RejectsSelf", func(t *testing.T) { - t.Parallel() + require.NoError(t, err, "variant %s should succeed", tc.name) + } - client := coderdtest.New(t, nil) - ownerResp := coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitLong) - owner, err := client.User(ctx, ownerResp.UserID.String()) - require.NoError(t, err) - err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ - OldEmail: owner.Email, - NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()), - }) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + // The API clears OTP state, revokes tokens, and records the audit event. + memberClient, member := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) + oldEmail := member.Email + newEmail := fmt.Sprintf("Updated-%s@example.com", uuid.NewString()) + oldToken := memberClient.SessionToken() + extraToken, err := client.CreateToken(ctx, member.ID.String(), codersdk.CreateTokenRequest{}) + require.NoError(t, err) + err = db.UpdateUserHashedOneTimePasscode(dbauthz.AsSystemRestricted(ctx), database.UpdateUserHashedOneTimePasscodeParams{ + ID: member.ID, + HashedOneTimePasscode: []byte("hashed-passcode"), + OneTimePasscodeExpiresAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true}, }) + require.NoError(t, err) + auditor.ResetLogs() - t.Run("Validation", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, nil) - owner := coderdtest.CreateFirstUser(t, client) - _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - ctx := testutil.Context(t, testutil.WaitLong) - - for _, req := range []codersdk.UpdateUserEmailRequest{ - {OldEmail: " " + target.Email, NewEmail: "new@example.com"}, - {OldEmail: target.Email, NewEmail: target.Email}, - {OldEmail: strings.ToUpper(target.Email), NewEmail: target.Email}, - } { - err := client.UpdateUserEmail(ctx, req) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) - } + err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ + OldEmail: oldEmail, + NewEmail: newEmail, }) + require.NoError(t, err) - t.Run("NotFoundAndConflict", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, nil) - owner := coderdtest.CreateFirstUser(t, client) - _, target := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - _, existing := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID) - ctx := testutil.Context(t, testutil.WaitLong) + sideEffectsUser, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), member.ID) + require.NoError(t, err) + require.Nil(t, sideEffectsUser.HashedOneTimePasscode) + require.False(t, sideEffectsUser.OneTimePasscodeExpiresAt.Valid) - err := client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ - OldEmail: fmt.Sprintf("missing-%s@example.com", uuid.NewString()), - NewEmail: fmt.Sprintf("new-%s@example.com", uuid.NewString()), - }) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + for _, token := range []string{oldToken, extraToken.Key} { + revokedClient := codersdk.New(client.URL, codersdk.WithSessionToken(token)) + _, err := revokedClient.User(ctx, codersdk.Me) + require.Error(t, err) + } + _, err = client.User(ctx, codersdk.Me) + require.NoError(t, err) - err = client.UpdateUserEmail(ctx, codersdk.UpdateUserEmailRequest{ - OldEmail: target.Email, - NewEmail: strings.ToUpper(existing.Email), - }) - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusConflict, apiErr.StatusCode()) - }) + logs = auditor.AuditLogs() + require.Len(t, logs, 1) + require.Equal(t, owner.UserID, logs[0].UserID) + require.Equal(t, member.ID, logs[0].ResourceID) + require.Equal(t, database.AuditActionWrite, logs[0].Action) + require.Equal(t, int32(http.StatusNoContent), logs[0].StatusCode) + var fields map[string]string + require.NoError(t, json.Unmarshal(logs[0].AdditionalFields, &fields)) + require.Equal(t, oldEmail, fields["old_email"]) + require.Equal(t, newEmail, fields["new_email"]) } func TestUpdateUserProfile(t *testing.T) {