Files
xinmi/cli/cmd/auth/login_test.go
T

265 lines
11 KiB
Go
Raw Normal View History

package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
"github.com/Tencent/WeKnora/cli/internal/testutil"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeLoginService captures the email/password it received.
type fakeLoginService struct {
resp *sdk.LoginResponse
err error
got struct{ email, password string }
}
func (f *fakeLoginService) Login(_ context.Context, req sdk.LoginRequest) (*sdk.LoginResponse, error) {
f.got.email = req.Email
f.got.password = req.Password
return f.resp, f.err
}
// scriptedPrompter satisfies prompt.Prompter with predetermined values.
type scriptedPrompter struct{ email, password string }
func (s scriptedPrompter) Input(string, string) (string, error) { return s.email, nil }
func (s scriptedPrompter) Password(string) (string, error) { return s.password, nil }
func (s scriptedPrompter) Confirm(string, bool) (bool, error) { return true, nil }
func newTestFactoryWithConfig(t *testing.T, p prompt.Prompter) (*cmdutil.Factory, *secrets.MemStore) {
t.Helper()
testutil.XDGTempDir(t)
store := secrets.NewMemStore()
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Client: func() (*sdk.Client, error) { panic("client") },
Prompter: func() prompt.Prompter { return p },
Secrets: func() (secrets.Store, error) { return store, nil },
}, store
}
// seedActiveProfile writes an active profile to the (XDG-isolated) config so
// runLogin can resolve host/name from it - `auth login` authenticates the
// already-existing active profile rather than creating one. Returns nothing;
// callers read back via f.Config() to assert merge behavior.
func seedActiveProfile(t *testing.T, name string, prof config.Profile) {
t.Helper()
cfg := &config.Config{
CurrentProfile: name,
Profiles: map[string]config.Profile{name: prof},
}
require.NoError(t, config.Save(cfg))
}
func TestRunLogin_PasswordMode(t *testing.T) {
iostreams.SetForTest(t)
f, store := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "secret"})
seedActiveProfile(t, "prod", config.Profile{Host: "https://kb.example.com"})
svc := &fakeLoginService{resp: &sdk.LoginResponse{
Success: true,
Token: "jwt-access",
User: &sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7},
}}
require.NoError(t, runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
assert.Equal(t, "a@b.c", svc.got.email)
assert.Equal(t, "secret", svc.got.password)
got, _ := store.Get("prod", "access")
assert.Equal(t, "jwt-access", got)
cfg, _ := f.Config()
assert.Equal(t, "https://kb.example.com", cfg.Profiles["prod"].Host, "host must be preserved from the seeded profile")
}
func TestRunLogin_WithToken(t *testing.T) {
iostreams.SetForTest(t)
f, store := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
return &sdk.AuthUser{ID: "u1", Email: "ci@example.com", TenantID: 7}, nil
})
defer restore()
opts := &LoginOptions{
WithToken: true,
StdinReader: strings.NewReader(" sk-1234 \n"),
}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
got, _ := store.Get("ci", "api_key")
assert.Equal(t, "sk-1234", got)
cfg, _ := f.Config()
assert.Equal(t, "ci@example.com", cfg.Profiles["ci"].User, "validator-returned user should be persisted")
assert.Equal(t, uint64(7), cfg.Profiles["ci"].TenantID)
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host, "host preserved from seeded profile")
}
func TestRunLogin_WithToken_JSONReportsAPIKeyMode(t *testing.T) {
out, _ := iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
// API-key validation hits /auth/me, which DOES return a user. mode
// must still be reported as api-key (derived from credential type,
// not from "did the server return a user").
return &sdk.AuthUser{ID: "u1", Email: "ci@example.com", TenantID: 7}, nil
})
defer restore()
opts := &LoginOptions{WithToken: true, StdinReader: strings.NewReader("sk-1234")}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, nil))
var env struct {
Data struct {
Mode string `json:"mode"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.Equal(t, "api-key", env.Data.Mode, "api-key login must report mode=api-key, not bearer")
}
func TestRunLogin_WithToken_ServerRejects(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
// Use the SDK-format HTTP error message so ClassifyHTTPError detects
// this as an HTTP 401, not a transport/network failure.
return nil, errors.New("HTTP error 401: invalid api key")
})
defer restore()
opts := &LoginOptions{
WithToken: true,
StdinReader: strings.NewReader("sk-bad"),
}
err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthBadCredential, typed.Code,
"server-side rejection must surface as auth.bad_credential, not persist the key")
}
// stubAPIKeyValidator swaps defaultAPIKeyValidator for the test and returns
// a restore func to defer.
func stubAPIKeyValidator(fn apiKeyValidator) func() {
saved := defaultAPIKeyValidator
defaultAPIKeyValidator = fn
return func() { defaultAPIKeyValidator = saved }
}
func TestRunLogin_WithToken_Empty(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
// Validator must NOT be called when stdin is empty - verify by setting
// a panic-on-call sentinel.
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
t.Fatal("validator should not be called on empty stdin")
return nil, nil
})
defer restore()
opts := &LoginOptions{
WithToken: true,
StdinReader: strings.NewReader(""),
}
err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "input.missing_flag")
}
func TestRunLogin_BadHost(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
// A non-http host stored on the active profile must be rejected by the
// post-resolution validateHost check (config corruption guard).
seedActiveProfile(t, "bad", config.Profile{Host: "ftp://nope"})
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
func TestRunLogin_LoginRefused(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "x"})
seedActiveProfile(t, "p", config.Profile{Host: "https://x"})
svc := &fakeLoginService{resp: &sdk.LoginResponse{Success: false, Message: "bad password"}}
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "auth.bad_credential")
}
func TestValidateHost(t *testing.T) {
require.NoError(t, validateHost("https://kb.example.com"))
require.NoError(t, validateHost("http://localhost:8080"))
require.Error(t, validateHost(""))
require.Error(t, validateHost("ftp://x"))
require.Error(t, validateHost("not a url"))
}
// TestLogin_NoHostNoName asserts the --host / --name flags are gone: `auth
// login` now authenticates the active profile (create it via `profile add`).
func TestLogin_NoHostNoName(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
cmd := NewCmdLogin(f, nil)
assert.Nil(t, cmd.Flags().Lookup("host"), "--host flag must be removed")
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
assert.NotNil(t, cmd.Flags().Lookup("with-token"), "--with-token must remain")
}
// TestLogin_NoActiveProfile_Errors asserts that with no active profile
// configured, login returns a clear auth.unauthenticated error pointing the
// user at `profile add`.
func TestLogin_NoActiveProfile_Errors(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
// No seedActiveProfile: empty config.
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
assert.Contains(t, err.Error(), "profile add")
}
// TestLogin_UsesActiveProfileHost asserts login persists credentials under the
// active profile using that profile's stored host, and that an existing User
// is NOT clobbered when the server (API-key validator) returns it unchanged.
func TestLogin_UsesActiveProfileHost(t *testing.T) {
iostreams.SetForTest(t)
f, store := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
// Pre-seed "prod" with a host AND a user that must survive re-login.
seedActiveProfile(t, "prod", config.Profile{Host: "https://prod.example.com", User: "existing@owner.com"})
restore := stubAPIKeyValidator(func(_ context.Context, host, _ string) (*sdk.AuthUser, error) {
assert.Equal(t, "https://prod.example.com", host, "validator must be called with the active profile's host")
// Server returns nil user (e.g. /auth/me gave no email) - must NOT
// wipe the existing User.
return &sdk.AuthUser{}, nil
})
defer restore()
opts := &LoginOptions{WithToken: true, StdinReader: strings.NewReader("sk-prod")}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
got, _ := store.Get("prod", "api_key")
assert.Equal(t, "sk-prod", got, "api key must be stored under the active profile")
cfg, _ := f.Config()
prof := cfg.Profiles["prod"]
assert.Equal(t, "https://prod.example.com", prof.Host, "host must be preserved, not clobbered")
assert.Equal(t, "existing@owner.com", prof.User, "existing User must NOT be wiped when server returns none")
assert.NotEmpty(t, prof.APIKeyRef, "api-key ref must be set")
assert.Equal(t, "prod", cfg.CurrentProfile, "active profile unchanged")
}