mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-30 19:33:05 +08:00
feat(cli): add auth logout and auth list commands
gh / lark / gcloud / stripe all ship a logout command and a way to
enumerate stored credentials on day one. WeKnora's `auth` subtree had
only login + status, leaving no documented purge path for keyring
secrets — a real concern for `--with-token` (sk-…) and JWT flows that
write credentials to OS keychains.
auth logout [--name <ctx>] [--all] [--json]
Clears keyring + file-fallback secrets (access / refresh / api_key
slots) for the named context (default: current) or every context
with --all. Removes the context entry from ~/.config/weknora/config.yaml
and clears current_context if the removed entry was active.
Mirrors `gh auth logout` and `lark auth logout`. As gh documents,
this does NOT revoke server-side — for API keys users must rotate in
the server UI, JWTs continue to be accepted until expiry.
auth list [--json]
Renders a compact table (NAME / HOST / USER / MODE) with the active
context marked `*`. Reads only config.yaml — no network, no keyring
touch. Mode is inferred from which credential ref is set (api_key
→ "api-key", token → "password"; both → "password" wins).
Mirrors gh's per-host enumeration (gh auth status iterates accounts)
and lark `auth list`. For weknora the contexts file already had this
data — the command is a thin renderer to match user muscle memory.
Deferred to a follow-up release:
- auth refresh + transparent 401 retry in the SDK (we already persist
refresh_token at login but never spend it; explicit gap)
- login --web browser OAuth flow (requires a server-side endpoint)
- auth token printer (cheap; defer with the rest)
Tests: 24 cli packages green. New: cmd/auth/logout_test.go (current
context, named, --all, no-contexts, unknown-name, no-current-no-flag,
mutex flags) + cmd/auth/list_test.go (human render, empty, JSON
envelope, inferMode edge cases). AGENTS.md command-surface note adds
the four-command auth subtree; screenshot section 4 adds `auth list`
alongside `auth status`.
This commit is contained in:
@@ -102,6 +102,12 @@ The command tree follows `<noun> <verb>` (gh style). Verbs are:
|
||||
| `upload` | Bulk write content | `doc upload <file>` |
|
||||
| `use` | Switch active selection | `context use <name>` |
|
||||
|
||||
`auth` subtree: `login` / `logout` / `list` / `status`. Mirrors gh's
|
||||
`auth login / logout / status / switch / list`-style surface; weknora uses
|
||||
`context use` instead of `auth switch` because contexts carry host + tenant
|
||||
on top of credentials. Token refresh is deferred to v0.3 (will land as
|
||||
transparent 401 retry in the SDK plus an explicit `auth refresh` command).
|
||||
|
||||
Top-level RAG / connectivity verbs: `chat`, `search`, `api`, `link`,
|
||||
`auth`, `doctor`, `version`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Package auth holds the cobra commands for authentication
|
||||
// (login / status / logout / refresh). v0.0 ships login + status; logout
|
||||
// lands in v0.4 and refresh in v0.3.
|
||||
// (login / logout / list / status). Token refresh is deferred to v0.3 once
|
||||
// the SDK gains automatic 401 retry against the stored refresh_token.
|
||||
package auth
|
||||
|
||||
import (
|
||||
@@ -24,6 +24,8 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
Run: func(c *cobra.Command, _ []string) { _ = c.Help() },
|
||||
}
|
||||
cmd.AddCommand(NewCmdLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdLogout(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
// ListOptions captures `weknora auth list` flag state.
|
||||
type ListOptions struct {
|
||||
JSONOut bool
|
||||
}
|
||||
|
||||
// listEntry is one row in the rendered list / one element of envelope.data.
|
||||
type listEntry struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
User string `json:"user,omitempty"`
|
||||
Mode string `json:"mode"` // "api-key" / "password" / "unknown"
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora auth list`. Mirrors gh's per-host enumeration
|
||||
// and lark `auth list`: render one row per registered context, marking the
|
||||
// active one. Reads only ~/.config/weknora/config.yaml — no network, no
|
||||
// keyring touch.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured authentication contexts",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
return runList(opts, f)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(opts *ListOptions, f *cmdutil.Factory) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := make([]listEntry, 0, len(cfg.Contexts))
|
||||
for name, c := range cfg.Contexts {
|
||||
entries = append(entries, listEntry{
|
||||
Name: name,
|
||||
Host: c.Host,
|
||||
User: c.User,
|
||||
Mode: inferMode(c.APIKeyRef, c.TokenRef),
|
||||
Current: name == cfg.CurrentContext,
|
||||
})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
|
||||
if opts.JSONOut {
|
||||
return cmdutil.NewJSONExporter().Write(iostreams.IO.Out,
|
||||
format.Success(entries, &format.Meta{Context: cfg.CurrentContext}))
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` to create one.")
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, " NAME\tHOST\tUSER\tMODE")
|
||||
for _, e := range entries {
|
||||
marker := " "
|
||||
if e.Current {
|
||||
marker = "* "
|
||||
}
|
||||
fmt.Fprintf(tw, "%s%s\t%s\t%s\t%s\n", marker, e.Name, e.Host, dashIfEmpty(e.User), e.Mode)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// inferMode reports which credential shape the context was logged in with.
|
||||
// A context with both refs set (which shouldn't happen with the current
|
||||
// login flow but might appear in hand-edited configs) is treated as
|
||||
// password — JWT wins because it's the more capable mode.
|
||||
func inferMode(apiKeyRef, tokenRef string) string {
|
||||
switch {
|
||||
case tokenRef != "":
|
||||
return "password"
|
||||
case apiKeyRef != "":
|
||||
return "api-key"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func dashIfEmpty(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
func newListFactory(cfg *config.Config) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_HumanRender(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
"prod": {Host: "https://prod", User: "alice@example.com", TokenRef: "keychain://prod/access"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: "keychain://staging/api_key"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runList(&ListOptions{}, newListFactory(cfg)))
|
||||
|
||||
got := out.String()
|
||||
// One row per context, current marked with `*`.
|
||||
assert.Contains(t, got, "* prod")
|
||||
assert.Contains(t, got, " staging")
|
||||
// Mode column.
|
||||
assert.Contains(t, got, "password")
|
||||
assert.Contains(t, got, "api-key")
|
||||
// Sorted alphabetically — prod after staging? No: "prod" < "staging".
|
||||
assert.Less(t, strings.Index(got, "prod"), strings.Index(got, "staging"),
|
||||
"contexts should render sorted by name")
|
||||
}
|
||||
|
||||
func TestList_Empty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
require.NoError(t, runList(&ListOptions{}, newListFactory(&config.Config{})))
|
||||
assert.Contains(t, out.String(), "No contexts configured")
|
||||
}
|
||||
|
||||
func TestList_JSONEnvelope(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
"prod": {Host: "https://prod", User: "alice", TokenRef: "tok"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: "key"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runList(&ListOptions{JSONOut: true}, newListFactory(cfg)))
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []listEntry `json:"data"`
|
||||
Meta struct {
|
||||
Context string `json:"context"`
|
||||
} `json:"_meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "prod", env.Meta.Context)
|
||||
require.Len(t, env.Data, 2)
|
||||
// Sorted: prod < staging.
|
||||
assert.Equal(t, "prod", env.Data[0].Name)
|
||||
assert.True(t, env.Data[0].Current)
|
||||
assert.Equal(t, "password", env.Data[0].Mode)
|
||||
assert.Equal(t, "staging", env.Data[1].Name)
|
||||
assert.False(t, env.Data[1].Current)
|
||||
assert.Equal(t, "api-key", env.Data[1].Mode)
|
||||
}
|
||||
|
||||
func TestList_InferModeUnknown(t *testing.T) {
|
||||
// Hand-edited config with neither ref set — surface "unknown" rather than
|
||||
// pretending the context is a valid login.
|
||||
assert.Equal(t, "unknown", inferMode("", ""))
|
||||
assert.Equal(t, "password", inferMode("", "tok"))
|
||||
assert.Equal(t, "api-key", inferMode("key", ""))
|
||||
assert.Equal(t, "password", inferMode("key", "tok"), "JWT wins when both set")
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
)
|
||||
|
||||
// LogoutOptions captures `weknora auth logout` flag state.
|
||||
type LogoutOptions struct {
|
||||
Name string // --name: target a specific context (default: current)
|
||||
All bool // --all: clear every context
|
||||
JSONOut bool
|
||||
}
|
||||
|
||||
// logoutResult is the typed payload emitted under data.
|
||||
type logoutResult struct {
|
||||
Removed []string `json:"removed"`
|
||||
}
|
||||
|
||||
// NewCmdLogout builds `weknora auth logout`. Mirrors `gh auth logout` and
|
||||
// `lark auth logout`: clears stored credentials (keyring + file fallback) and
|
||||
// removes the context entry from config.yaml. No server-side revocation —
|
||||
// matches gh's documented behavior ("does not revoke … users must do that
|
||||
// manually").
|
||||
func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &LogoutOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove stored credentials for a context",
|
||||
Long: `Clear keyring + file-fallback secrets for one context (or all of
|
||||
them with --all) and drop the context entry from ~/.config/weknora/config.yaml.
|
||||
|
||||
Note: this does NOT revoke the credential server-side — for API keys, you
|
||||
must rotate them in the server UI; for JWT, the token will continue to be
|
||||
accepted until it expires.`,
|
||||
Example: ` weknora auth logout # current context
|
||||
weknora auth logout --name staging # specific context
|
||||
weknora auth logout --all`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
return runLogout(opts, f)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Name, "name", "", "Context to log out (defaults to the current context)")
|
||||
cmd.Flags().BoolVar(&opts.All, "all", false, "Log out of every configured context")
|
||||
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
|
||||
cmd.MarkFlagsMutuallyExclusive("name", "all")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runLogout(opts *LogoutOptions, f *cmdutil.Factory) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Contexts) == 0 {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no contexts configured; nothing to log out")
|
||||
}
|
||||
|
||||
targets, err := pickLogoutTargets(opts, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range targets {
|
||||
clearContextSecrets(store, cfg.Contexts[name], name)
|
||||
delete(cfg.Contexts, name)
|
||||
}
|
||||
// If we removed the active context, pick a remaining one (deterministic by
|
||||
// map order would be flaky — leave CurrentContext empty so the next
|
||||
// invocation surfaces a clear "no current context" error rather than
|
||||
// silently switching).
|
||||
if _, stillExists := cfg.Contexts[cfg.CurrentContext]; !stillExists {
|
||||
cfg.CurrentContext = ""
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
}
|
||||
|
||||
if opts.JSONOut {
|
||||
return cmdutil.NewJSONExporter().Write(iostreams.IO.Out,
|
||||
format.Success(logoutResult{Removed: targets}, nil))
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d context(s): %s\n", len(targets), strings.Join(targets, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickLogoutTargets resolves the set of contexts to clear from flags + config.
|
||||
func pickLogoutTargets(opts *LogoutOptions, cfg *config.Config) ([]string, error) {
|
||||
if opts.All {
|
||||
names := make([]string, 0, len(cfg.Contexts))
|
||||
for n := range cfg.Contexts {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
name := opts.Name
|
||||
if name == "" {
|
||||
name = cfg.CurrentContext
|
||||
}
|
||||
if name == "" {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputMissingFlag,
|
||||
"no current context set; pass --name <ctx> or --all")
|
||||
}
|
||||
if _, ok := cfg.Contexts[name]; !ok {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeLocalContextNotFound,
|
||||
fmt.Sprintf("context %q not found in config", name))
|
||||
}
|
||||
return []string{name}, nil
|
||||
}
|
||||
|
||||
// clearContextSecrets best-effort deletes every secret slot the context
|
||||
// references. Errors are swallowed because a missing secret is a no-op
|
||||
// (tested in keyring_test.go) — we don't want a stale ref to block logout.
|
||||
func clearContextSecrets(store secrets.Store, c config.Context, name string) {
|
||||
if c.TokenRef != "" {
|
||||
_ = store.Delete(name, "access")
|
||||
}
|
||||
if c.RefreshRef != "" {
|
||||
_ = store.Delete(name, "refresh")
|
||||
}
|
||||
if c.APIKeyRef != "" {
|
||||
_ = store.Delete(name, "api_key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/secrets"
|
||||
)
|
||||
|
||||
// newLogoutFactory builds a Factory whose Config closure mutates the supplied
|
||||
// cfg in place — runLogout writes back via config.Save which touches disk, so
|
||||
// tests use t.Setenv("XDG_CONFIG_HOME", t.TempDir()) at the call site to
|
||||
// isolate the on-disk file.
|
||||
func newLogoutFactory(t *testing.T, cfg *config.Config, store secrets.Store) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func isolateConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
}
|
||||
|
||||
func TestLogout_CurrentContext(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "access", "jwt-prod"))
|
||||
require.NoError(t, store.Set("prod", "refresh", "rfr-prod"))
|
||||
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
"prod": {Host: "https://prod", TokenRef: store.Ref("prod", "access"), RefreshRef: store.Ref("prod", "refresh")},
|
||||
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Empty(t, cfg.CurrentContext, "current_context should clear when removed")
|
||||
assert.NotContains(t, cfg.Contexts, "prod")
|
||||
assert.Contains(t, cfg.Contexts, "staging", "non-target context untouched")
|
||||
|
||||
// Secrets gone for the removed context, kept for the survivor.
|
||||
if _, err := store.Get("prod", "access"); err == nil {
|
||||
t.Error("prod access secret should be deleted")
|
||||
}
|
||||
if v, _ := store.Get("staging", "api_key"); v != "sk-staging" {
|
||||
t.Errorf("staging secret unexpectedly cleared: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout_NamedContext(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
"prod": {Host: "https://prod", TokenRef: "tok"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{Name: "staging"}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Equal(t, "prod", cfg.CurrentContext, "current_context untouched when removing other")
|
||||
assert.NotContains(t, cfg.Contexts, "staging")
|
||||
assert.Contains(t, cfg.Contexts, "prod")
|
||||
}
|
||||
|
||||
func TestLogout_All(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{
|
||||
"prod": {Host: "https://prod"},
|
||||
"staging": {Host: "https://staging"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{All: true}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Empty(t, cfg.Contexts)
|
||||
assert.Empty(t, cfg.CurrentContext)
|
||||
}
|
||||
|
||||
func TestLogout_NoContexts(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{}
|
||||
err := runLogout(&LogoutOptions{}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
func TestLogout_UnknownName(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentContext: "prod",
|
||||
Contexts: map[string]config.Context{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
err := runLogout(&LogoutOptions{Name: "ghost"}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeLocalContextNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestLogout_NoCurrentNoFlag(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
Contexts: map[string]config.Context{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
err := runLogout(&LogoutOptions{}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
|
||||
}
|
||||
|
||||
// TestLogout_Cobra exercises the cobra layer for the mutually-exclusive
|
||||
// --name + --all flag pair.
|
||||
func TestLogout_Cobra_FlagsMutuallyExclusive(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{Contexts: map[string]config.Context{"a": {}}}
|
||||
cmd := NewCmdLogout(newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"--name", "a", "--all"})
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
require.Error(t, cmd.Execute())
|
||||
}
|
||||
Reference in New Issue
Block a user