mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. Deprecates `login_type=none` (legacy passwordless machine users) in favour of premium **service accounts**, and migrates existing accounts off the deprecated path while preserving their identity. Resolves [DEVEX-226]. ## What this does - **Creation is gated** — `POST /users` and `coder users create` reject `login_type=none` (and the deprecated `--disable-login`) unless a service account is requested. - **Existing users are converted** — migration `000554_legacy_none_login_to_password` rewrites legacy non-system, non–service-account `login_type='none'` accounts to `login_type='password'`. Email addresses are **preserved** and existing API tokens remain valid. Admins can set a password if interactive login is desired. ## Why convert to `password` and not `is_service_account`? Migration `000433_add_is_service_account_to_users` adds two CHECK constraints: - `users_email_not_empty`: `(is_service_account = true) = (email = '')` - `users_service_account_login_type`: `is_service_account = false OR login_type = 'none'` Turning a real, email-bearing `login_type=none` user into a service account would require **blanking their email**. Converting to `password` instead preserves the account and its email. > ⚠️ **Breaking / one-way.** The `down` migration cannot restore which users originally had `login_type='none'`. Decision log - **Goal:** move existing `login_type=none` users off the deprecated path while preserving their identity/email. - **Constraint discovered:** the `is_service_account` CHECK constraints (migration `000433`) make a literal `none → service account` conversion require blanking emails, so this PR converts to `password` instead to keep emails intact. - **Implementation:** creation-gating in `cli/usercreate.go` and `coderd/users.go`, matching test updates, plus the `000554_legacy_none_login_to_password.{up,down}.sql` migration. - **CI fix:** the branch was behind `main` and its migration originally numbered `000534`, which collided with main's `000534_drop_chat_model_configs_provider`. Merged `main` and renumbered to `000554` (next free after main's `000553`). `make gen` produces no drift (the migration is data-only). > The service-account conversion alternative (#27182, which blanked emails) was closed in favour of this password-preserving approach. > > Docs follow-up: #27333. [DEVEX-226]: https://linear.app/issue/DEVEX-226 --------- Co-authored-by: Sushant P <zenithwolf1000@users.noreply.github.com>
201 lines
5.8 KiB
Go
201 lines
5.8 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/cli/clitest"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/coder/v2/testutil/expecter"
|
|
)
|
|
|
|
func TestUserCreate(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("Prompts", func(t *testing.T) {
|
|
t.Parallel()
|
|
logger := testutil.Logger(t)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := coderdtest.New(t, nil)
|
|
coderdtest.CreateFirstUser(t, client)
|
|
inv, root := clitest.New(t, "users", "create")
|
|
clitest.SetupConfig(t, client, root)
|
|
doneChan := make(chan struct{})
|
|
stdout := expecter.NewAttachedToInvocation(t, inv)
|
|
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
|
|
go func() {
|
|
defer close(doneChan)
|
|
err := inv.Run()
|
|
assert.NoError(t, err)
|
|
}()
|
|
matches := []string{
|
|
"Username", "dean",
|
|
"Email", "dean@coder.com",
|
|
"Full name (optional):", "Mr. Dean Deanington",
|
|
}
|
|
for i := 0; i < len(matches); i += 2 {
|
|
match := matches[i]
|
|
value := matches[i+1]
|
|
stdout.ExpectMatch(ctx, match)
|
|
stdin.WriteLine(value)
|
|
}
|
|
_ = testutil.TryReceive(ctx, t, doneChan)
|
|
created, err := client.User(ctx, matches[1])
|
|
require.NoError(t, err)
|
|
assert.Equal(t, matches[1], created.Username)
|
|
assert.Equal(t, matches[3], created.Email)
|
|
assert.Equal(t, matches[5], created.Name)
|
|
})
|
|
|
|
t.Run("PromptsNoName", func(t *testing.T) {
|
|
t.Parallel()
|
|
logger := testutil.Logger(t)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := coderdtest.New(t, nil)
|
|
coderdtest.CreateFirstUser(t, client)
|
|
inv, root := clitest.New(t, "users", "create")
|
|
clitest.SetupConfig(t, client, root)
|
|
doneChan := make(chan struct{})
|
|
stdout := expecter.NewAttachedToInvocation(t, inv)
|
|
stdin := testutil.NewWriterAttachedToInvocation(t, logger.Named("stdin"), inv)
|
|
go func() {
|
|
defer close(doneChan)
|
|
err := inv.Run()
|
|
assert.NoError(t, err)
|
|
}()
|
|
matches := []string{
|
|
"Username", "noname",
|
|
"Email", "noname@coder.com",
|
|
"Full name (optional):", "",
|
|
}
|
|
for i := 0; i < len(matches); i += 2 {
|
|
match := matches[i]
|
|
value := matches[i+1]
|
|
stdout.ExpectMatch(ctx, match)
|
|
stdin.WriteLine(value)
|
|
}
|
|
_ = testutil.TryReceive(ctx, t, doneChan)
|
|
created, err := client.User(ctx, matches[1])
|
|
require.NoError(t, err)
|
|
assert.Equal(t, matches[1], created.Username)
|
|
assert.Equal(t, matches[3], created.Email)
|
|
assert.Empty(t, created.Name)
|
|
})
|
|
|
|
t.Run("Args", func(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
coderdtest.CreateFirstUser(t, client)
|
|
args := []string{
|
|
"users", "create",
|
|
"-e", "dean@coder.com",
|
|
"-u", "dean",
|
|
"-n", "Mr. Dean Deanington",
|
|
"-p", "1n5ecureP4ssw0rd!",
|
|
}
|
|
inv, root := clitest.New(t, args...)
|
|
clitest.SetupConfig(t, client, root)
|
|
err := inv.Run()
|
|
require.NoError(t, err)
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
created, err := client.User(ctx, "dean")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, args[3], created.Email)
|
|
assert.Equal(t, args[5], created.Username)
|
|
assert.Equal(t, args[7], created.Name)
|
|
})
|
|
|
|
t.Run("ArgsNoName", func(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
coderdtest.CreateFirstUser(t, client)
|
|
args := []string{
|
|
"users", "create",
|
|
"-e", "dean@coder.com",
|
|
"-u", "dean",
|
|
"-p", "1n5ecureP4ssw0rd!",
|
|
}
|
|
inv, root := clitest.New(t, args...)
|
|
clitest.SetupConfig(t, client, root)
|
|
err := inv.Run()
|
|
require.NoError(t, err)
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
created, err := client.User(ctx, args[5])
|
|
require.NoError(t, err)
|
|
assert.Equal(t, args[3], created.Email)
|
|
assert.Equal(t, args[5], created.Username)
|
|
assert.Empty(t, created.Name)
|
|
})
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
err string
|
|
}{
|
|
{
|
|
name: "ServiceAccount",
|
|
args: []string{"--service-account", "-u", "dean"},
|
|
err: "Premium feature",
|
|
},
|
|
{
|
|
name: "ServiceAccountLoginType",
|
|
args: []string{"--service-account", "-u", "dean", "--login-type", "none"},
|
|
err: "You cannot use --login-type with --service-account",
|
|
},
|
|
{
|
|
name: "ServiceAccountDisableLogin",
|
|
args: []string{"--service-account", "-u", "dean", "--disable-login"},
|
|
err: "You cannot use --disable-login with --service-account",
|
|
},
|
|
{
|
|
name: "ServiceAccountEmail",
|
|
args: []string{"--service-account", "-u", "dean", "--email", "dean@coder.com"},
|
|
err: "You cannot use --email with --service-account",
|
|
},
|
|
{
|
|
name: "ServiceAccountPassword",
|
|
args: []string{"--service-account", "-u", "dean", "--password", "1n5ecureP4ssw0rd!"},
|
|
err: "You cannot use --password with --service-account",
|
|
},
|
|
{
|
|
name: "DisableLogin",
|
|
args: []string{"--disable-login", "-u", "dean"},
|
|
err: "--disable-login is deprecated. Use --service-account for machine-to-machine access.",
|
|
},
|
|
{
|
|
name: "LoginTypeNone",
|
|
args: []string{"--login-type", "none", "-u", "dean"},
|
|
err: "Login type 'none' is deprecated. Use --service-account for machine-to-machine access.",
|
|
},
|
|
{
|
|
name: "DisableLoginWithLoginType",
|
|
args: []string{"--disable-login", "--login-type", "password", "-u", "dean"},
|
|
err: "You cannot specify both --disable-login and --login-type",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
coderdtest.CreateFirstUser(t, client)
|
|
inv, root := clitest.New(t, append([]string{"users", "create"}, tt.args...)...)
|
|
clitest.SetupConfig(t, client, root)
|
|
err := inv.Run()
|
|
if tt.err == "" {
|
|
require.NoError(t, err)
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
created, err := client.User(ctx, "dean")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, codersdk.LoginTypeNone, created.LoginType)
|
|
} else {
|
|
require.Error(t, err)
|
|
require.ErrorContains(t, err, tt.err)
|
|
}
|
|
})
|
|
}
|
|
}
|