WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
// Package auth holds the cobra commands for authentication
|
||||
// (login / logout / list / refresh / status / token).
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// Credential-mode tokens used in the JSON output of auth list / login /
|
||||
// status / token. The string names describe the HTTP credential type rather
|
||||
// than the login flow (e.g. JWT → bearer regardless of whether it was
|
||||
// obtained via password or refresh) so an agent can branch directly on the
|
||||
// header to construct: `Authorization: Bearer <token>` (ModeBearer) or
|
||||
// `X-API-Key: <token>` (ModeAPIKey).
|
||||
const (
|
||||
ModeBearer = "bearer"
|
||||
ModeAPIKey = "api-key"
|
||||
ModeUnknown = "unknown"
|
||||
)
|
||||
|
||||
// modeFromRefs maps the per-profile TokenRef / APIKeyRef presence to a
|
||||
// canonical credential-mode token. Bearer wins when both are present -
|
||||
// matches the precedence in cmdutil.buildClient.
|
||||
func modeFromRefs(apiKeyRef, tokenRef string) string {
|
||||
switch {
|
||||
case tokenRef != "":
|
||||
return ModeBearer
|
||||
case apiKeyRef != "":
|
||||
return ModeAPIKey
|
||||
default:
|
||||
return ModeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// NewCmdAuth builds the `weknora auth` command tree and registers its
|
||||
// subcommands. Called from cli/cmd/root.go.
|
||||
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage authentication credentials and profiles",
|
||||
}
|
||||
cmd.AddCommand(NewCmdLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdLogout(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdRefresh(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdToken(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestNewCmdAuth_TreeShape(t *testing.T) {
|
||||
cmd := NewCmdAuth(&cmdutil.Factory{})
|
||||
assert.Equal(t, "auth", cmd.Use)
|
||||
subs := map[string]*cobra.Command{}
|
||||
for _, c := range cmd.Commands() {
|
||||
subs[c.Use] = c
|
||||
}
|
||||
assert.Contains(t, subs, "login")
|
||||
assert.Contains(t, subs, "status")
|
||||
}
|
||||
|
||||
func TestNewCmdLogin_FlagsRegistered(t *testing.T) {
|
||||
cmd := NewCmdLogin(&cmdutil.Factory{}, nil)
|
||||
// --format is a persistent root flag (v0.7); only per-command flags here.
|
||||
assert.NotNil(t, cmd.Flags().Lookup("with-token"), "flag with-token missing")
|
||||
// login authenticates the active profile; host/name come from config
|
||||
// (created via `profile add`), so these flags are gone.
|
||||
assert.Nil(t, cmd.Flags().Lookup("host"), "auth login must not declare --host (v0.9)")
|
||||
assert.Nil(t, cmd.Flags().Lookup("name"), "auth login must not declare --name (v0.9)")
|
||||
// --profile is the global persistent override; local registration would
|
||||
// silently shadow it.
|
||||
assert.Nil(t, cmd.Flags().Lookup("profile"), "auth login must not declare a local --profile flag (use the global --profile)")
|
||||
}
|
||||
|
||||
func TestNewCmdLogin_InvokesRunF(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
called := false
|
||||
store := secrets.NewMemStore()
|
||||
f := &cmdutil.Factory{
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
cmd := NewCmdLogin(f, func(_ context.Context, opts *LoginOptions, _ *cmdutil.FormatOptions, _ *cmdutil.Factory, _ LoginService) error {
|
||||
called = true
|
||||
// host/profile resolve inside runLogin from the active profile;
|
||||
// the runF seam just receives the parsed flags.
|
||||
assert.True(t, opts.WithToken)
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--with-token"})
|
||||
require.NoError(t, cmd.Execute())
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestLoginServiceFor(t *testing.T) {
|
||||
assert.Nil(t, loginServiceFor(""))
|
||||
assert.NotNil(t, loginServiceFor("https://x"))
|
||||
}
|
||||
|
||||
func TestPersistAPIKey_WritesContext(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
store := secrets.NewMemStore()
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return config.Load() },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
// persistAPIKey MERGES into the existing active profile record (created
|
||||
// via `profile add`) rather than creating it; seed it first.
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{"ci": {Host: "https://kb.example.com"}},
|
||||
}))
|
||||
opts := &LoginOptions{
|
||||
Host: "https://kb.example.com",
|
||||
Profile: "ci",
|
||||
APIKey: "sk-zzz",
|
||||
}
|
||||
require.NoError(t, persistAPIKey(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
|
||||
v, _ := store.Get("ci", "api_key")
|
||||
assert.Equal(t, "sk-zzz", v)
|
||||
cfg, _ := f.Config()
|
||||
assert.Equal(t, "ci", cfg.CurrentProfile, "active profile unchanged by login")
|
||||
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host, "existing host preserved by merge")
|
||||
// APIKeyRef should be the mem:// URI from the store's Ref method.
|
||||
assert.Equal(t, "mem://ci/api_key", cfg.Profiles["ci"].APIKeyRef)
|
||||
}
|
||||
|
||||
func TestPersistJWT_StoresBothTokens(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
store := secrets.NewMemStore()
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return config.Load() },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
opts := &LoginOptions{
|
||||
Host: "https://x",
|
||||
Profile: "p",
|
||||
}
|
||||
resp := &sdk.LoginResponse{
|
||||
Token: "jwt-acc",
|
||||
RefreshToken: "jwt-ref",
|
||||
User: &sdk.AuthUser{Email: "a@b.c", TenantID: 7},
|
||||
}
|
||||
require.NoError(t, persistJWT(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, resp))
|
||||
a, _ := store.Get("p", "access")
|
||||
r, _ := store.Get("p", "refresh")
|
||||
assert.Equal(t, "jwt-acc", a)
|
||||
assert.Equal(t, "jwt-ref", r)
|
||||
}
|
||||
|
||||
func TestReadStdinTrimmed(t *testing.T) {
|
||||
out, err := readStdinTrimmed(strings.NewReader(" hello \n"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", out)
|
||||
|
||||
out, err = readStdinTrimmed(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", out)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Package auth — dryrun_validation_test.go asserts that --dry-run on
|
||||
// auth subcommands rejects identically to the live path. Before the
|
||||
// surrounding fix, validation lived in runX() and was reached only after
|
||||
// HandleDryRun short-circuited, so --dry-run accepted invocations the live
|
||||
// path would reject.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"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"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// authDryRunFactory builds a Factory whose Client closure panics if invoked —
|
||||
// dry-run must early-exit before any SDK call. Config is supplied via the cfg
|
||||
// argument so each test gets isolated state.
|
||||
func authDryRunFactory(t *testing.T, cfg *config.Config) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
Client: func() (*sdk.Client, error) {
|
||||
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
|
||||
return nil, nil
|
||||
},
|
||||
Prompter: func() prompt.Prompter {
|
||||
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
|
||||
return nil
|
||||
},
|
||||
Secrets: func() (secrets.Store, error) { return secrets.NewMemStore(), nil },
|
||||
}
|
||||
}
|
||||
|
||||
// withRootHarnessAuth wraps an auth subcommand under a synthetic root cmd
|
||||
// that registers the global persistent flags.
|
||||
func withRootHarnessAuth(sub *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
root.AddCommand(sub)
|
||||
root.SetArgs(append([]string{sub.Name()}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
// TestAuthLogout_DryRun_RejectsNoProfiles: empty config → live path returns
|
||||
// auth.unauthenticated; --dry-run must do the same.
|
||||
func TestAuthLogout_DryRun_RejectsNoProfiles(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{}
|
||||
root := withRootHarnessAuth(NewCmdLogout(authDryRunFactory(t, cfg)),
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject empty config")
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
// TestAuthLogout_DryRun_RejectsMissingActiveProfile: the active profile name
|
||||
// (e.g. set via the global --profile) has no config entry → live path returns
|
||||
// local.profile_not_found; --dry-run must do the same.
|
||||
func TestAuthLogout_DryRun_RejectsMissingActiveProfile(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ghost", // active profile with no matching entry
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
root := withRootHarnessAuth(NewCmdLogout(authDryRunFactory(t, cfg)),
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject a missing active profile")
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeLocalProfileNotFound, typed.Code)
|
||||
}
|
||||
|
||||
// TestAuthRefresh_DryRun_RejectsNoCurrentProfile: no current profile → live
|
||||
// path returns auth.unauthenticated; --dry-run must do the same.
|
||||
func TestAuthRefresh_DryRun_RejectsNoCurrentProfile(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{}
|
||||
root := withRootHarnessAuth(NewCmdRefresh(authDryRunFactory(t, cfg)),
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed))
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
// TestAuthRefresh_DryRun_RejectsMissingRefreshToken: API-key-only profile →
|
||||
// live path returns input.invalid_argument ("no refresh token"); --dry-run
|
||||
// must do the same.
|
||||
func TestAuthRefresh_DryRun_RejectsMissingRefreshToken(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", APIKeyRef: "k"},
|
||||
},
|
||||
}
|
||||
root := withRootHarnessAuth(NewCmdRefresh(authDryRunFactory(t, cfg)),
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed))
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
type ListOptions struct{}
|
||||
|
||||
// authListFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `auth list`. Each entry is a per-profile summary row.
|
||||
var authListFields = []string{
|
||||
"name", "host", "user", "mode", "current",
|
||||
}
|
||||
|
||||
type listEntry struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
User string `json:"user,omitempty"`
|
||||
Mode string `json:"mode"` // ModeBearer / ModeAPIKey / ModeUnknown
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora auth list`. Per-host enumeration: render one
|
||||
// row per registered profile, marking the active one. Reads only
|
||||
// ~/.config/weknora/config.yaml - no network, no keyring touch.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured authentication profiles",
|
||||
Long: `Show every configured profile (name, host, user, mode, current). Read-only; no network or keyring access.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
return runList(fopts, f)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, authListFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "list configured profiles and their credential mode (bearer / api-key)",
|
||||
Examples: []string{"weknora auth list", "weknora auth list --jq '.data[].name'"},
|
||||
Output: "envelope.data is an array of profiles with name, host, mode, and which is active",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := make([]listEntry, 0, len(cfg.Profiles))
|
||||
for name, c := range cfg.Profiles {
|
||||
entries = append(entries, listEntry{
|
||||
Name: name,
|
||||
Host: c.Host,
|
||||
User: c.User,
|
||||
Mode: modeFromRefs(c.APIKeyRef, c.TokenRef),
|
||||
Current: name == cfg.CurrentProfile,
|
||||
})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(entries))}
|
||||
return fopts.Emit(iostreams.IO.Out, entries, meta)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "No profiles 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, format.DashIfEmpty(e.User), e.Mode)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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_TextRender(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"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(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(cfg)))
|
||||
|
||||
got := out.String()
|
||||
// One row per profile, current marked with `*`.
|
||||
assert.Contains(t, got, "* prod")
|
||||
assert.Contains(t, got, " staging")
|
||||
// Mode column.
|
||||
assert.Contains(t, got, ModeBearer)
|
||||
assert.Contains(t, got, ModeAPIKey)
|
||||
// Sorted alphabetically - prod after staging? No: "prod" < "staging".
|
||||
assert.Less(t, strings.Index(got, "prod"), strings.Index(got, "staging"),
|
||||
"profiles should render sorted by name")
|
||||
}
|
||||
|
||||
func TestList_Empty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(&config.Config{})))
|
||||
assert.Contains(t, out.String(), "No profiles configured")
|
||||
}
|
||||
|
||||
func TestList_JSON_BareArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", User: "alice", TokenRef: "tok"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: "key"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, newListFactory(cfg)))
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []listEntry `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
got := env.Data
|
||||
require.Len(t, got, 2)
|
||||
// Sorted: prod < staging.
|
||||
assert.Equal(t, "prod", got[0].Name)
|
||||
assert.True(t, got[0].Current)
|
||||
assert.Equal(t, ModeBearer, got[0].Mode)
|
||||
assert.Equal(t, "staging", got[1].Name)
|
||||
assert.False(t, got[1].Current)
|
||||
assert.Equal(t, ModeAPIKey, got[1].Mode)
|
||||
}
|
||||
|
||||
func TestModeFromRefs(t *testing.T) {
|
||||
// Hand-edited config with neither ref set - surface "unknown" rather
|
||||
// than pretending the profile is a valid login.
|
||||
assert.Equal(t, ModeUnknown, modeFromRefs("", ""))
|
||||
assert.Equal(t, ModeBearer, modeFromRefs("", "tok"))
|
||||
assert.Equal(t, ModeAPIKey, modeFromRefs("key", ""))
|
||||
assert.Equal(t, ModeBearer, modeFromRefs("key", "tok"), "JWT wins when both set")
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"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/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// authLoginFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `auth login`. The post-login summary has no token values - they stay in the
|
||||
// keyring; agents who need to verify the credential should re-run
|
||||
// `auth status`.
|
||||
var authLoginFields = []string{
|
||||
"profile", "host", "mode", "email", "tenant_id",
|
||||
}
|
||||
|
||||
// LoginOptions is the configuration captured from flags + prompts. Host and
|
||||
// Profile are resolved from the active profile in config (not from flags) -
|
||||
// `auth login` authenticates the already-existing active profile, created
|
||||
// beforehand with `weknora profile add <name> --host <h> --use`.
|
||||
type LoginOptions struct {
|
||||
Host string // resolved from the active profile's Host in config
|
||||
Profile string // resolved active profile name (honors global --profile)
|
||||
WithToken bool // --with-token: read api key from stdin instead of prompting password
|
||||
APIKey string // populated by --with-token from stdin
|
||||
Email string
|
||||
Password string
|
||||
StdinReader io.Reader // override for tests
|
||||
}
|
||||
|
||||
// LoginService is the narrow SDK surface auth login depends on.
|
||||
// *sdk.Client satisfies it implicitly via the new Login(ctx, LoginRequest)
|
||||
// signature added in client/auth.go.
|
||||
type LoginService interface {
|
||||
Login(ctx context.Context, req sdk.LoginRequest) (*sdk.LoginResponse, error)
|
||||
}
|
||||
|
||||
// apiKeyValidator probes /auth/me with the supplied API key so a bad key
|
||||
// fails fast at `auth login --with-token` time rather than on the next
|
||||
// authenticated call.
|
||||
//
|
||||
// Returns the resolved user (used to populate Profile.User / TenantID at
|
||||
// rest, so later `auth list` reflects who owns the key).
|
||||
type apiKeyValidator func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error)
|
||||
|
||||
// defaultAPIKeyValidator builds a one-shot SDK client with the supplied key
|
||||
// and calls /auth/me. Side-effect-free; no persistence.
|
||||
var defaultAPIKeyValidator apiKeyValidator = func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error) {
|
||||
resp, err := sdk.NewClient(host, sdk.WithAPIKey(apiKey)).GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resp.Success || resp.Data.User == nil {
|
||||
return nil, fmt.Errorf("server rejected the API key (no user returned)")
|
||||
}
|
||||
return resp.Data.User, nil
|
||||
}
|
||||
|
||||
// NewCmdLogin builds the `weknora auth login` command. runF is the testable
|
||||
// entrypoint (left nil for production; see cli/cmd/auth/login_test.go).
|
||||
func NewCmdLogin(f *cmdutil.Factory, runF func(context.Context, *LoginOptions, *cmdutil.FormatOptions, *cmdutil.Factory, LoginService) error) *cobra.Command {
|
||||
opts := &LoginOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate the active profile against its WeKnora server",
|
||||
Long: `Authenticate the active profile by email + password (interactive prompt) or
|
||||
pipe an API key with --with-token.
|
||||
|
||||
` + "`auth login`" + ` operates on the active profile (override with the global
|
||||
--profile flag). Create the profile first with
|
||||
` + "`weknora profile add <name> --host <h> --use`" + `, then run ` + "`weknora auth login`" + `.
|
||||
|
||||
Credentials are persisted to the OS keyring when available; otherwise to a
|
||||
0600 file under $XDG_CONFIG_HOME/weknora/secrets.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
if opts.StdinReader == nil {
|
||||
opts.StdinReader = iostreams.IO.In
|
||||
}
|
||||
if runF != nil {
|
||||
// Test seam: the injected runF supplies its own service and
|
||||
// (where needed) seeds opts.Host / opts.Profile itself.
|
||||
return runF(c.Context(), opts, fopts, f, nil)
|
||||
}
|
||||
return runLogin(c.Context(), opts, fopts, f, nil)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password")
|
||||
cmdutil.AddFormatFlag(cmd, authLoginFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "authenticate the active profile; --with-token reads an API key from stdin (non-interactive)",
|
||||
Examples: []string{`echo "$WEKNORA_API_KEY" | weknora auth login --with-token`},
|
||||
Output: "envelope.data is {profile, host, mode, email, tenant_id} on success (email matches `auth status`)",
|
||||
Warnings: []string{
|
||||
"a profile must exist and be active first (`weknora profile add <n> --host <url> --use`)",
|
||||
"password login is interactive-only (no flags) — agents must use --with-token with the key piped to stdin",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// resolveActiveProfile loads config and resolves the active profile (honoring
|
||||
// the global --profile override) plus its persisted Host. `auth login`
|
||||
// authenticates an already-existing profile; profile creation is `profile
|
||||
// add`'s job. Returns typed errors when no active profile is configured or
|
||||
// the profile lacks a host.
|
||||
func resolveActiveProfile(f *cmdutil.Factory) (name, host string, err error) {
|
||||
// f.Config() already folds the global --profile / WEKNORA_PROFILE override
|
||||
// into cfg.CurrentProfile (same source f.ActiveProfile reads), so one load
|
||||
// resolves the active profile — matches logout/refresh.
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
active := cfg.CurrentProfile
|
||||
if active == "" {
|
||||
msg := "no active profile; run `weknora profile add <name> --host <h> --use` first"
|
||||
if envActive, kind := cmdutil.EnvCredential(); envActive {
|
||||
msg = "no active profile to log in — you are already authenticated this session via " + kind +
|
||||
"; `auth login` only persists a named profile, so run `weknora profile add <name> --host <h> --use` first if you want one"
|
||||
}
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, msg)
|
||||
}
|
||||
prof, ok := cfg.Profiles[active]
|
||||
if !ok {
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("active profile %q not found in config", active))
|
||||
}
|
||||
if prof.Host == "" {
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
|
||||
fmt.Sprintf("profile %q has no host", active))
|
||||
}
|
||||
return active, prof.Host, nil
|
||||
}
|
||||
|
||||
// loginServiceFor returns a fresh SDK client targeting host. login.go cannot
|
||||
// reuse Factory.Client because that closure requires an existing profile.
|
||||
func loginServiceFor(host string) LoginService {
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
return sdk.NewClient(host)
|
||||
}
|
||||
|
||||
func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc LoginService) error {
|
||||
// Resolve the target profile + host from the active profile in config.
|
||||
// `auth login` no longer takes --host / --name; it authenticates the
|
||||
// already-existing active profile (override via the global --profile).
|
||||
name, host, err := resolveActiveProfile(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Profile = name
|
||||
opts.Host = host
|
||||
if svc == nil {
|
||||
svc = loginServiceFor(host)
|
||||
}
|
||||
|
||||
if err := validateHost(opts.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reject shell-metacharacter / path-like names up-front so opts.Profile
|
||||
// stays safe to interpolate into the keyring namespace, config.yaml
|
||||
// keys, and envelope.error.retry_argv. Matches `profile add`.
|
||||
if err := cmdutil.ValidateProfileName(opts.Profile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.WithToken {
|
||||
key, err := readStdinTrimmed(opts.StdinReader)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read stdin")
|
||||
}
|
||||
if key == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputMissingFlag, "--with-token requires an API key piped to stdin")
|
||||
}
|
||||
opts.APIKey = key
|
||||
// Validate against the server before persisting so a typo'd /
|
||||
// expired / wrong-host key fails fast at login time. The probe
|
||||
// is /auth/me - read-only, side-effect-free.
|
||||
user, err := defaultAPIKeyValidator(ctx, opts.Host, key)
|
||||
if err != nil {
|
||||
// Transport errors (connection refused, DNS failure) must not be
|
||||
// surfaced as auth.bad_credential — the key may be fine but the
|
||||
// host is unreachable. Classify via WrapHTTP so network errors
|
||||
// get CodeNetworkError and the hint points at `weknora doctor`.
|
||||
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
|
||||
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "validate API key: check host reachability")
|
||||
}
|
||||
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "validate API key")
|
||||
}
|
||||
return persistAPIKey(opts, fopts, f, user)
|
||||
}
|
||||
|
||||
// Interactive: prompt for email + password. svc is always set by now
|
||||
// (loginServiceFor returns non-nil because resolveActiveProfile guarantees
|
||||
// a non-empty host).
|
||||
if opts.Email == "" || opts.Password == "" {
|
||||
p := f.Prompter()
|
||||
if opts.Email == "" {
|
||||
email, err := p.Input("Email", "")
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "email prompt")
|
||||
}
|
||||
opts.Email = email
|
||||
}
|
||||
if opts.Password == "" {
|
||||
pw, err := p.Password("Password")
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "password prompt")
|
||||
}
|
||||
opts.Password = pw
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := svc.Login(ctx, sdk.LoginRequest{Email: opts.Email, Password: opts.Password})
|
||||
if err != nil {
|
||||
// Transport errors (connection refused, DNS failure) must not be
|
||||
// surfaced as auth.bad_credential — credentials may be fine but the
|
||||
// host is unreachable. Classify so network errors get CodeNetworkError.
|
||||
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
|
||||
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "login: check host reachability")
|
||||
}
|
||||
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "login")
|
||||
}
|
||||
if !resp.Success || resp.Token == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthBadCredential, fmt.Sprintf("login refused: %s", resp.Message))
|
||||
}
|
||||
|
||||
return persistJWT(opts, fopts, f, resp)
|
||||
}
|
||||
|
||||
// persistAPIKey saves the --with-token API key and writes the profile.
|
||||
// user is the principal returned by /auth/me during pre-persist validation,
|
||||
// used to populate Profile.User / TenantID so `auth list` reflects who
|
||||
// owns the key.
|
||||
func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, user *sdk.AuthUser) error {
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warnOnFileFallback(store)
|
||||
if err := store.Set(opts.Profile, "api_key", opts.APIKey); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save api key")
|
||||
}
|
||||
// Switching credential mode: this profile is now an API-key profile, so
|
||||
// any leftover JWT refs (from a prior password login) are stale. Set the
|
||||
// api-key ref and clear the token/refresh refs; keep Host + existing
|
||||
// User/TenantID (merged in saveProfileRef via applyUser).
|
||||
mutate := func(prof *config.Profile) {
|
||||
prof.APIKeyRef = store.Ref(opts.Profile, "api_key")
|
||||
prof.TokenRef = ""
|
||||
prof.RefreshRef = ""
|
||||
applyUser(prof, user)
|
||||
}
|
||||
return saveProfileRef(opts, fopts, f, mutate, ModeAPIKey, user)
|
||||
}
|
||||
|
||||
// persistJWT saves access + refresh tokens and writes the profile.
|
||||
func persistJWT(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, resp *sdk.LoginResponse) error {
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warnOnFileFallback(store)
|
||||
if err := store.Set(opts.Profile, "access", resp.Token); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save access token")
|
||||
}
|
||||
if resp.RefreshToken != "" {
|
||||
if err := store.Set(opts.Profile, "refresh", resp.RefreshToken); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save refresh token")
|
||||
}
|
||||
}
|
||||
// Switching to a JWT profile: set token refs and drop any stale api-key
|
||||
// ref; keep Host + existing User/TenantID (merged in saveProfileRef via applyUser).
|
||||
mutate := func(prof *config.Profile) {
|
||||
prof.TokenRef = store.Ref(opts.Profile, "access")
|
||||
prof.RefreshRef = store.Ref(opts.Profile, "refresh")
|
||||
prof.APIKeyRef = ""
|
||||
applyUser(prof, resp.User)
|
||||
}
|
||||
return saveProfileRef(opts, fopts, f, mutate, ModeBearer, resp.User)
|
||||
}
|
||||
|
||||
// applyUser overwrites prof.User / prof.TenantID only when the server actually
|
||||
// returned them. A nil user (or empty email) must NOT wipe an existing User
|
||||
// that was set during `profile add` or a prior login.
|
||||
func applyUser(prof *config.Profile, user *sdk.AuthUser) {
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
if user.Email != "" {
|
||||
prof.User = user.Email
|
||||
}
|
||||
if user.TenantID != 0 {
|
||||
prof.TenantID = user.TenantID
|
||||
}
|
||||
}
|
||||
|
||||
// loginResult is the typed payload emitted by `--format json`. mode is derived from
|
||||
// whether the server returned a user (password flow) vs API-key flow.
|
||||
type loginResult struct {
|
||||
Profile string `json:"profile"`
|
||||
Host string `json:"host"`
|
||||
Mode string `json:"mode"` // ModeBearer or ModeAPIKey
|
||||
// Email is the authenticated principal's email. Named "email" (not
|
||||
// "user") so the identity field matches `auth status`, which also
|
||||
// exposes it as `email` — one key for one concept across both commands.
|
||||
Email string `json:"email,omitempty"`
|
||||
TenantID uint64 `json:"tenant_id,omitempty"`
|
||||
}
|
||||
|
||||
// saveProfileRef MERGES the new credential refs into the EXISTING profile
|
||||
// record and prints success. The active profile already exists (created via
|
||||
// `profile add`, carrying Host and possibly User/TenantID); mutate sets only
|
||||
// the credential refs + any server-returned user, so re-login never clobbers
|
||||
// the host or wipes an existing user. cfg.CurrentProfile is left untouched -
|
||||
// `auth login` authenticates the already-active profile, it doesn't switch.
|
||||
func saveProfileRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, mutate func(*config.Profile), mode string, user *sdk.AuthUser) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]config.Profile{}
|
||||
}
|
||||
prof := cfg.Profiles[opts.Profile] // existing record (Host, User, TenantID)
|
||||
mutate(&prof)
|
||||
cfg.Profiles[opts.Profile] = prof
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
// mode reflects the credential actually stored (passed by the caller),
|
||||
// not whether the server happened to return a user — an API-key login
|
||||
// validates via /auth/me and DOES get a user, so deriving mode from
|
||||
// user != nil would mislabel it as bearer.
|
||||
result := loginResult{Profile: opts.Profile, Host: opts.Host, Mode: mode}
|
||||
if user != nil {
|
||||
result.Email = user.Email
|
||||
result.TenantID = user.TenantID
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
who := opts.Profile
|
||||
if user != nil {
|
||||
who = user.Email
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (profile=%s)\n", opts.Host, who, opts.Profile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHost rejects empty / non-http URLs early so we surface a clean
|
||||
// flag error instead of failing inside the SDK transport.
|
||||
func validateHost(host string) error {
|
||||
_, err := cmdutil.NormalizeHost(host)
|
||||
return err
|
||||
}
|
||||
|
||||
// warnOnFileFallback prints a one-shot stderr advisory when the secrets
|
||||
// store fell back to the plaintext 0600 file backend (keychain unavailable
|
||||
// - typical on headless CI, WSL without DBus, agent containers). Helps
|
||||
// users notice that credentials are NOT in the OS keychain before they're
|
||||
// surprised by it later. doctor's credential_storage check carries the
|
||||
// same info but agents that bypass doctor would otherwise miss it.
|
||||
func warnOnFileFallback(store secrets.Store) {
|
||||
if _, isFile := store.(*secrets.FileStore); !isFile {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(iostreams.IO.Err, "warning: OS keychain unavailable - credentials will be saved to a 0600 file under $XDG_CONFIG_HOME/weknora/secrets/.")
|
||||
fmt.Fprintln(iostreams.IO.Err, " install / unlock the keyring (or use `weknora doctor` to inspect) for OS-backed storage.")
|
||||
}
|
||||
|
||||
// readStdinTrimmed reads all of r and returns the result with surrounding
|
||||
// whitespace stripped. Empty result is returned as-is for the caller to
|
||||
// validate.
|
||||
func readStdinTrimmed(r io.Reader) (string, error) {
|
||||
if r == nil {
|
||||
return "", nil
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
)
|
||||
|
||||
type LogoutOptions struct {
|
||||
All bool // --all: clear every profile
|
||||
Yes bool // sourced from the global -y/--yes persistent flag
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// authLogoutFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `auth logout`. The result is the list of profile names that were
|
||||
// logged out.
|
||||
var authLogoutFields = []string{"removed"}
|
||||
|
||||
// logoutResult is the typed payload emitted under data.
|
||||
type logoutResult struct {
|
||||
Removed []string `json:"removed"`
|
||||
}
|
||||
|
||||
// NewCmdLogout builds `weknora auth logout`. Clears stored credentials
|
||||
// (keyring + file fallback) and removes the profile entry from config.yaml.
|
||||
// No server-side revocation - local-only credential clear.
|
||||
func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &LogoutOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove stored credentials for a profile",
|
||||
Long: `Clear keyring + file-fallback secrets for one profile (or all of
|
||||
them with --all) and drop the profile 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 # active profile
|
||||
weknora --profile staging auth logout # specific profile
|
||||
weknora auth logout --all`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
// Pure-local validation runs before the dry-run gate so --dry-run
|
||||
// rejects identically to the live path. Same typed errors as
|
||||
// runLogout (kept there for direct-call callers).
|
||||
cfg, cfgErr := f.Config()
|
||||
if cfgErr != nil {
|
||||
return cfgErr
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
|
||||
}
|
||||
if _, err := pickLogoutTargets(opts, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.DryRun {
|
||||
planArgs := map[string]any{"profile": cfg.CurrentProfile}
|
||||
if opts.All {
|
||||
planArgs = map[string]any{"all": true}
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
|
||||
Action: "auth.logout",
|
||||
Args: planArgs,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runLogout(opts, fopts, f)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.All, "all", false, "Log out of every configured profile")
|
||||
cmdutil.AddFormatFlag(cmd, authLogoutFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "auth.logout")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "clear stored credentials for the active profile (or --all); the profile itself stays registered (host preserved) so `auth login` can re-auth it — use `profile remove` to delete the profile entirely",
|
||||
Examples: []string{
|
||||
"weknora auth logout",
|
||||
"weknora --profile staging auth logout",
|
||||
"weknora auth logout --all",
|
||||
},
|
||||
Output: "envelope.data is {removed: [profile names cleared]}",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"auth logout clears local credentials for this profile; server-side session continues until expiry.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
|
||||
}
|
||||
|
||||
targets, err := pickLogoutTargets(opts, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Destructive-write protocol: confirm before clearing credentials.
|
||||
scope := fmt.Sprintf("%d profile(s) [%s]", len(targets), strings.Join(targets, ", "))
|
||||
retryCmd := []string{"weknora", "auth", "logout", "-y"}
|
||||
if opts.All {
|
||||
retryCmd = []string{"weknora", "auth", "logout", "--all", "-y"}
|
||||
}
|
||||
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "auth credentials", scope, "auth.logout", retryCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range targets {
|
||||
clearProfileSecrets(store, cfg.Profiles[name], name)
|
||||
// Keep the profile registered (its host stays) — only clear the
|
||||
// credential refs so it reads as logged-out and can be re-authed with
|
||||
// `auth login`. Deleting the profile entirely is `profile remove`'s job;
|
||||
// that clean logout(who)/remove(what) split matches gh / lark.
|
||||
p := cfg.Profiles[name]
|
||||
p.APIKeyRef = ""
|
||||
p.TokenRef = ""
|
||||
p.RefreshRef = ""
|
||||
cfg.Profiles[name] = p
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, logoutResult{Removed: targets}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d profile(s): %s\n", len(targets), strings.Join(targets, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickLogoutTargets resolves the set of profiles to clear from flags + config.
|
||||
func pickLogoutTargets(opts *LogoutOptions, cfg *config.Config) ([]string, error) {
|
||||
if opts.All {
|
||||
names := make([]string, 0, len(cfg.Profiles))
|
||||
for n := range cfg.Profiles {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
name := cfg.CurrentProfile
|
||||
if name == "" {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputMissingFlag,
|
||||
"no active profile set; use the global --profile <profile> or --all")
|
||||
}
|
||||
if _, ok := cfg.Profiles[name]; !ok {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile %q not found in config", name))
|
||||
}
|
||||
return []string{name}, nil
|
||||
}
|
||||
|
||||
// clearProfileSecrets best-effort deletes every secret slot the profile
|
||||
// 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 clearProfileSecrets(store secrets.Store, c config.Profile, 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,165 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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 },
|
||||
Prompter: func() prompt.Prompter { return &prompt.AgentPrompter{} },
|
||||
}
|
||||
}
|
||||
|
||||
func isolateConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
}
|
||||
|
||||
func TestLogout_CurrentProfile(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{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"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{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Equal(t, "prod", cfg.CurrentProfile, "active profile stays selected — logout clears creds, not the profile")
|
||||
require.Contains(t, cfg.Profiles, "prod", "profile stays registered after logout")
|
||||
assert.Empty(t, cfg.Profiles["prod"].APIKeyRef, "credential ref cleared")
|
||||
assert.Empty(t, cfg.Profiles["prod"].TokenRef, "credential ref cleared")
|
||||
assert.Empty(t, cfg.Profiles["prod"].RefreshRef, "credential ref cleared")
|
||||
assert.Equal(t, "https://prod", cfg.Profiles["prod"].Host, "host preserved for re-login")
|
||||
assert.Contains(t, cfg.Profiles, "staging", "non-target profile untouched")
|
||||
|
||||
// Secrets gone for the removed profile, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogout_ActiveProfileViaOverride exercises targeting a non-default
|
||||
// profile. Production resolves this via the global --profile flag (which
|
||||
// rewrites cfg.CurrentProfile in Factory.Config); here we simulate that by
|
||||
// setting CurrentProfile=staging directly, since runLogout's single target is
|
||||
// the active profile.
|
||||
func TestLogout_ActiveProfileViaOverride(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "staging", // global --profile staging resolves to this
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", TokenRef: "tok"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
require.Contains(t, cfg.Profiles, "staging", "target profile stays registered (creds cleared, not removed)")
|
||||
assert.Empty(t, cfg.Profiles["staging"].APIKeyRef, "target's credential ref cleared")
|
||||
assert.Contains(t, cfg.Profiles, "prod", "non-target profile untouched")
|
||||
}
|
||||
|
||||
func TestLogout_All(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod"},
|
||||
"staging": {Host: "https://staging"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{All: true, Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
// --all clears every profile's credentials but keeps the profiles registered.
|
||||
require.NotEmpty(t, cfg.Profiles, "profiles stay registered after logout --all")
|
||||
for name, p := range cfg.Profiles {
|
||||
assert.Empty(t, p.APIKeyRef, "%s api-key ref cleared", name)
|
||||
assert.Empty(t, p.TokenRef, "%s token ref cleared", name)
|
||||
assert.Empty(t, p.RefreshRef, "%s refresh ref cleared", name)
|
||||
}
|
||||
assert.Equal(t, "prod", cfg.CurrentProfile, "active selection is preserved; logout clears creds, not the profile")
|
||||
}
|
||||
|
||||
func TestLogout_NoProfiles(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{}
|
||||
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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)
|
||||
}
|
||||
|
||||
// TestLogout_ActiveProfileMissing covers the corrupt-config case where the
|
||||
// active profile name (e.g. set via --profile ghost) has no matching entry.
|
||||
func TestLogout_ActiveProfileMissing(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ghost", // points at a non-existent entry
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeLocalProfileNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestLogout_NoCurrentNoFlag(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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_NoNameFlag asserts the --name flag is gone (logout now targets
|
||||
// the active profile / global --profile) while --all stands alone.
|
||||
func TestLogout_NoNameFlag(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {}}}
|
||||
cmd := NewCmdLogout(newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
|
||||
assert.NotNil(t, cmd.Flags().Lookup("all"), "--all flag must remain")
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type RefreshOptions struct {
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// authRefreshFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `auth refresh`. Token values are intentionally omitted - see refreshResult.
|
||||
var authRefreshFields = []string{"profile"}
|
||||
|
||||
// refreshResult is the typed payload emitted under data on success. Token
|
||||
// values are intentionally NOT included - emitting them would leak secrets
|
||||
// into stdout / agent transcripts. Agents needing to verify the new token
|
||||
// can re-run `weknora auth status` (live API check).
|
||||
type refreshResult struct {
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
// NewCmdRefresh builds `weknora auth refresh`. Renews the JWT access
|
||||
// token by spending the stored refresh_token via POST /auth/refresh -
|
||||
// the standard OAuth refresh-token grant.
|
||||
//
|
||||
// API-key profiles are rejected - they have no refresh semantic;
|
||||
// rotate the key via the server UI instead.
|
||||
func NewCmdRefresh(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &RefreshOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "refresh",
|
||||
Short: "Renew the JWT access token using the stored refresh token",
|
||||
Long: `Reads the refresh token previously stored by ` + "`weknora auth login`" + ` and
|
||||
exchanges it for a new access + refresh token pair via POST /api/v1/auth/refresh.
|
||||
Both new tokens replace the existing entries in the OS keyring.
|
||||
|
||||
API-key profiles are rejected with input.invalid_argument - they have no
|
||||
refresh semantic. Rotate the key in the server UI instead.`,
|
||||
Example: ` weknora auth refresh # refresh the active profile
|
||||
weknora --profile staging auth refresh # refresh a specific profile`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Pure-local validation runs before the dry-run gate so --dry-run
|
||||
// rejects identically to the live path. Same typed errors as
|
||||
// runRefresh (kept there for direct-call callers).
|
||||
cfg, cfgErr := f.Config()
|
||||
if cfgErr != nil {
|
||||
return cfgErr
|
||||
}
|
||||
name := cfg.CurrentProfile
|
||||
if name == "" {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no active profile configured; run `weknora auth login` to set one up")
|
||||
}
|
||||
prof, ok := cfg.Profiles[name]
|
||||
if !ok {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile not found: %s", name))
|
||||
}
|
||||
if prof.Host == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
|
||||
fmt.Sprintf("profile %q has no host", name))
|
||||
}
|
||||
if prof.RefreshRef == "" {
|
||||
hint := "api-key profiles can't be refreshed - rotate the key in the server UI and run `weknora auth login --with-token`"
|
||||
if prof.APIKeyRef == "" {
|
||||
hint = "no refresh token stored - run `weknora auth login` to authenticate"
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("profile %q has no refresh token", name),
|
||||
Hint: hint,
|
||||
}
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "auth.refresh",
|
||||
Args: map[string]any{},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
return runRefresh(c.Context(), opts, fopts, f, defaultRefresher)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, authRefreshFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Renew the JWT access token for the active profile (override with the global --profile) using the stored refresh token. API-key profiles are rejected.",
|
||||
Output: "envelope.data has profile name that was refreshed",
|
||||
Examples: []string{
|
||||
"weknora auth refresh",
|
||||
"weknora --profile staging auth refresh",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// defaultRefresher constructs a fresh, unauthenticated SDK client targeting
|
||||
// host - the /auth/refresh endpoint reads the refresh token from the body,
|
||||
// so no bearer / api-key header is needed.
|
||||
func defaultRefresher(host string) cmdutil.Refresher {
|
||||
return sdk.NewClient(host)
|
||||
}
|
||||
|
||||
func runRefresh(ctx context.Context, opts *RefreshOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, refresherFor func(host string) cmdutil.Refresher) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := cfg.CurrentProfile
|
||||
if name == "" {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no active profile configured; run `weknora auth login` to set one up")
|
||||
}
|
||||
c, ok := cfg.Profiles[name]
|
||||
if !ok {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile not found: %s", name))
|
||||
}
|
||||
if c.Host == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
|
||||
fmt.Sprintf("profile %q has no host", name))
|
||||
}
|
||||
if c.RefreshRef == "" {
|
||||
hint := "api-key profiles can't be refreshed - rotate the key in the server UI and run `weknora auth login --with-token`"
|
||||
if c.APIKeyRef == "" {
|
||||
hint = "no refresh token stored - run `weknora auth login` to authenticate"
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("profile %q has no refresh token", name),
|
||||
Hint: hint,
|
||||
}
|
||||
}
|
||||
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cmdutil.RefreshAndPersist(ctx, store, refresherFor(c.Host), name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, refreshResult{Profile: name}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Refreshed access token for profile %s\n", name)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// fakeRefreshService scripts a RefreshToken response.
|
||||
type fakeRefreshService struct {
|
||||
resp *sdk.RefreshTokenResponse
|
||||
err error
|
||||
gotTok string
|
||||
}
|
||||
|
||||
func (f *fakeRefreshService) RefreshToken(_ context.Context, refreshToken string) (*sdk.RefreshTokenResponse, error) {
|
||||
f.gotTok = refreshToken
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
// stubSvc returns a closure conforming to the refresherFor signature; it
|
||||
// ignores host since the fake doesn't talk to the network.
|
||||
func stubSvc(s cmdutil.Refresher) func(string) cmdutil.Refresher {
|
||||
return func(string) cmdutil.Refresher { return s }
|
||||
}
|
||||
|
||||
func newRefreshFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(cfg))
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return config.Load() },
|
||||
Client: func() (*sdk.Client, error) { panic("client") },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_Happy(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "access", "old-access"))
|
||||
require.NoError(t, store.Set("prod", "refresh", "old-refresh"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {
|
||||
Host: "https://kb.example.com",
|
||||
TokenRef: "mem://prod/access",
|
||||
RefreshRef: "mem://prod/refresh",
|
||||
User: "alice@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{
|
||||
Success: true,
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
}}
|
||||
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
|
||||
|
||||
assert.Equal(t, "old-refresh", svc.gotTok, "must pass stored refresh token to SDK")
|
||||
gotAccess, _ := store.Get("prod", "access")
|
||||
gotRefresh, _ := store.Get("prod", "refresh")
|
||||
assert.Equal(t, "new-access", gotAccess)
|
||||
assert.Equal(t, "new-refresh", gotRefresh)
|
||||
}
|
||||
|
||||
// TestRefresh_ActiveProfileViaOverride exercises refreshing a non-default
|
||||
// profile. Production resolves this via the global --profile flag (which
|
||||
// rewrites cfg.CurrentProfile in Factory.Config); here we set
|
||||
// CurrentProfile=staging directly, since runRefresh's target is the active
|
||||
// profile.
|
||||
func TestRefresh_ActiveProfileViaOverride(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("staging", "refresh", "stg-refresh"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "staging", // global --profile staging resolves to this
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
|
||||
"staging": {Host: "https://stg", TokenRef: "mem://staging/access", RefreshRef: "mem://staging/refresh"},
|
||||
},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{
|
||||
Success: true, AccessToken: "new-stg-access", RefreshToken: "new-stg-refresh",
|
||||
}}
|
||||
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
|
||||
|
||||
assert.Equal(t, "stg-refresh", svc.gotTok, "active profile staging must be refreshed")
|
||||
// prod (non-active) is untouched
|
||||
if v, _ := store.Get("prod", "access"); v != "" {
|
||||
t.Errorf("prod must not have been touched, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefresh_NoNameFlag asserts the --name flag is gone; refresh targets the
|
||||
// active profile (override via the global --profile).
|
||||
func TestRefresh_NoNameFlag(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {Host: "https://a"}}}
|
||||
f := newRefreshFactory(t, cfg, secrets.NewMemStore())
|
||||
cmd := NewCmdRefresh(f)
|
||||
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
|
||||
}
|
||||
|
||||
func TestRefresh_NoCurrentProfile(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := newRefreshFactory(t, &config.Config{}, secrets.NewMemStore())
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
func TestRefresh_APIKeyContext(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("ci", "api_key", "sk-123"))
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{"ci": {Host: "https://kb", APIKeyRef: "mem://ci/api_key"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Hint, "api-key", "hint should explain api-key profiles cannot be refreshed")
|
||||
}
|
||||
|
||||
func TestRefresh_NoRefreshTokenStored(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
|
||||
},
|
||||
}
|
||||
// MemStore is empty - RefreshRef points to a slot that doesn't exist.
|
||||
f := newRefreshFactory(t, cfg, secrets.NewMemStore())
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthTokenExpired, typed.Code)
|
||||
assert.Contains(t, typed.Hint, "auth login")
|
||||
}
|
||||
|
||||
func TestRefresh_ServerRefused(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "refresh", "stale-refresh"))
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: false, Message: "refresh token expired"}}
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthTokenExpired, typed.Code)
|
||||
assert.Contains(t, typed.Hint, "auth login")
|
||||
// stored access must NOT have been overwritten with empty
|
||||
if v, _ := store.Get("prod", "access"); v == "" {
|
||||
// Was never set in this test, that's fine - main thing is no panic.
|
||||
_ = v
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_TransportError(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{err: errors.New("connection reset")}
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
// network/transport classified as network.error (mirrors auth login's
|
||||
// CodeAuthBadCredential mapping pattern; here we keep network errors
|
||||
// distinct since auth/refresh treats them as retryable).
|
||||
assert.Equal(t, cmdutil.CodeNetworkError, typed.Code)
|
||||
}
|
||||
|
||||
func TestRefresh_JSONOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: true, AccessToken: "a", RefreshToken: "r"}}
|
||||
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, stubSvc(svc)))
|
||||
|
||||
body := out.String()
|
||||
// payload must not leak the actual token values.
|
||||
assert.NotContains(t, body, "ok-refresh", "output must not leak refresh token")
|
||||
assert.NotContains(t, body, "\"a\"", "output must not leak the new access token")
|
||||
assert.NotContains(t, body, "\"r\"", "output must not leak the new refresh token")
|
||||
// must mention the profile name so agents can confirm what was refreshed
|
||||
assert.True(t, strings.Contains(body, "prod"), "output should reference the refreshed profile")
|
||||
// v0.7 envelope: ok:true is expected
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &env))
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// authStatusFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `auth status`. Single-resource shape: filter applies to data itself.
|
||||
var authStatusFields = []string{
|
||||
"profile", "host", "auth_source", "user_id", "username", "email", "is_active",
|
||||
"can_access_all_tenants", "tenant_id", "tenant_name",
|
||||
}
|
||||
|
||||
// StatusService is the narrow SDK surface auth status depends on.
|
||||
type StatusService interface {
|
||||
GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error)
|
||||
}
|
||||
|
||||
// statusResult is the typed payload emitted by `--format json`. Mirrors the
|
||||
// SDK AuthUser + AuthTenant projection so agents can branch on
|
||||
// can_access_all_tenants (cross-tenant admin) and is_active (disabled
|
||||
// account) without a second round-trip.
|
||||
type statusResult struct {
|
||||
Profile string `json:"profile"`
|
||||
Host string `json:"host,omitempty"`
|
||||
AuthSource string `json:"auth_source,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
IsActive bool `json:"is_active,omitempty"`
|
||||
CanAccessAllTenants bool `json:"can_access_all_tenants,omitempty"`
|
||||
TenantID uint64 `json:"tenant_id,omitempty"`
|
||||
TenantName string `json:"tenant_name,omitempty"`
|
||||
}
|
||||
|
||||
// NewCmdStatus builds the `weknora auth status` command.
|
||||
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show the active profile, principal, and token state",
|
||||
Long: `Live-check the active credential by calling /auth/me. Reports the user
|
||||
and tenant the server resolves the credential to.
|
||||
|
||||
Exits with auth.unauthenticated when the token is invalid or missing - run
|
||||
` + "`weknora auth login`" + ` (or ` + "`auth refresh`" + ` for JWT profiles) to recover.
|
||||
For JWT profiles the SDK transparently refreshes on 401, so this command
|
||||
usually only surfaces a hard auth failure.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runStatus(c.Context(), fopts, f, cli)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, authStatusFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "show the active profile, the authenticated principal, and token state",
|
||||
Examples: []string{"weknora auth status", "weknora auth status --jq .data.tenant_id"},
|
||||
Output: "envelope.data is {profile, host, auth_source, user_id, username, email, tenant_id, tenant_name, ...}; auth_source is 'profile + keyring' or a stateless env credential; exit 3 if unauthenticated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc StatusService) error {
|
||||
if svc == nil {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no SDK client available; run `weknora auth login`")
|
||||
}
|
||||
resp, err := svc.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch current user")
|
||||
}
|
||||
user := resp.Data.User
|
||||
tenant := resp.Data.Tenant
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Effective host + auth source. Stateless env credentials
|
||||
// (WEKNORA_TOKEN/WEKNORA_API_KEY) override the profile + keyring for the
|
||||
// client, so report the host they authenticate against (WEKNORA_HOST) and
|
||||
// that they are in effect — not the bypassed config profile's host.
|
||||
host := ""
|
||||
if c, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
|
||||
host = c.Host
|
||||
}
|
||||
authSource := "profile + keyring"
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
authSource = kind + " env (stateless)"
|
||||
if h := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); h != "" {
|
||||
host = h
|
||||
}
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
result := statusResult{Profile: cfg.CurrentProfile, Host: host, AuthSource: authSource}
|
||||
if user != nil {
|
||||
result.UserID = user.ID
|
||||
result.Username = user.Username
|
||||
result.Email = user.Email
|
||||
result.IsActive = user.IsActive
|
||||
result.CanAccessAllTenants = user.CanAccessAllTenants
|
||||
result.TenantID = user.TenantID
|
||||
}
|
||||
if tenant != nil {
|
||||
result.TenantName = tenant.Name
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
|
||||
fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentProfile)
|
||||
fmt.Fprintf(iostreams.IO.Out, "auth_source: %s\n", authSource)
|
||||
fmt.Fprintf(iostreams.IO.Out, "host: %s\n", host)
|
||||
if user != nil {
|
||||
fmt.Fprintf(iostreams.IO.Out, "user: %s (%s)\n", user.Email, user.ID)
|
||||
fmt.Fprintf(iostreams.IO.Out, "tenant: %d", user.TenantID)
|
||||
if tenant != nil {
|
||||
fmt.Fprintf(iostreams.IO.Out, " (%s)", tenant.Name)
|
||||
}
|
||||
fmt.Fprintln(iostreams.IO.Out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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/testutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeStatusService struct {
|
||||
resp *sdk.CurrentUserResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeStatusService) GetCurrentUser(_ context.Context) (*sdk.CurrentUserResponse, error) {
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func newCurrentUserResponse(user *sdk.AuthUser, tenant *sdk.AuthTenant) *sdk.CurrentUserResponse {
|
||||
r := &sdk.CurrentUserResponse{Success: true}
|
||||
r.Data.User = user
|
||||
r.Data.Tenant = tenant
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRunStatus_TextOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TenantID: 7},
|
||||
},
|
||||
}))
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return config.Load() },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
svc := &fakeStatusService{
|
||||
resp: newCurrentUserResponse(
|
||||
&sdk.AuthUser{ID: "u1", Email: "alice@example.com", TenantID: 7},
|
||||
&sdk.AuthTenant{ID: 7, Name: "Acme"},
|
||||
),
|
||||
}
|
||||
require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "profile: prod")
|
||||
assert.Contains(t, got, "auth_source: profile + keyring")
|
||||
assert.Contains(t, got, "host: https://kb.example.com")
|
||||
assert.Contains(t, got, "alice@example.com")
|
||||
assert.Contains(t, got, "Acme")
|
||||
}
|
||||
|
||||
func TestRunStatus_JSONOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://x"}},
|
||||
}))
|
||||
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
|
||||
svc := &fakeStatusService{resp: newCurrentUserResponse(&sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7}, nil)}
|
||||
require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, svc))
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Equal(t, "prod", env.Data["profile"], "profile field should be prod")
|
||||
assert.Contains(t, got, `"email":"a@b.c"`)
|
||||
}
|
||||
|
||||
func TestRunStatus_NoSDKClient(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &cmdutil.Factory{}, nil)
|
||||
require.Error(t, err)
|
||||
assert.True(t, cmdutil.IsAuthError(err))
|
||||
}
|
||||
|
||||
func TestRunStatus_SDKError_Transport(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
|
||||
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
|
||||
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: assert.AnError})
|
||||
require.Error(t, err)
|
||||
// Non-HTTP errors (DNS / TCP) are transport problems, not auth problems -
|
||||
// classify network.error so retry logic / exit code 7 / IsTransient apply.
|
||||
assert.True(t, cmdutil.IsTransient(err), "expected transient/network classification, got %v", err)
|
||||
}
|
||||
|
||||
func TestRunStatus_SDKError_HTTP401(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
|
||||
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
|
||||
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: errors.New("HTTP error 401: invalid token")})
|
||||
require.Error(t, err)
|
||||
assert.True(t, cmdutil.IsAuthError(err))
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
// authTokenFields lists fields surfaced in `--help` as a hint for `--jq`
|
||||
// projection. Single-resource shape: emits the bare token object directly.
|
||||
var authTokenFields = []string{"token", "mode", "profile"}
|
||||
|
||||
type tokenResult struct {
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"` // ModeBearer (JWT) or ModeAPIKey
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
// NewCmdToken builds `weknora auth token`. Prints the active profile's
|
||||
// credential to stdout for use in shell pipelines, e.g.
|
||||
//
|
||||
// WEKNORA_TOKEN=$(weknora auth token)
|
||||
// curl -H "Authorization: Bearer $WEKNORA_TOKEN" ... # JWT mode
|
||||
// curl -H "X-API-Key: $WEKNORA_TOKEN" ... # api-key mode
|
||||
//
|
||||
// The user is responsible for constructing the appropriate header -
|
||||
// `auth list` shows which mode each profile uses.
|
||||
//
|
||||
// Default output: raw token on stdout, no trailing newline (clean $(...)).
|
||||
// `--format json[=fields]` emits a bare {token, mode, profile} object.
|
||||
func NewCmdToken(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "token",
|
||||
Short: "Print the active profile's credential to stdout",
|
||||
Long: `Print the active profile's credential to stdout, with no trailing
|
||||
newline, suitable for shell command substitution.
|
||||
|
||||
The credential is the long-lived API key (mode: api-key) or the access JWT
|
||||
(mode: bearer), depending on how the profile was created. Run ` + "`weknora auth list`" + `
|
||||
to see which mode each profile uses, and construct the matching HTTP header:
|
||||
|
||||
Authorization: Bearer <token> # bearer mode
|
||||
X-API-Key: <token> # api-key mode
|
||||
|
||||
` + "`--profile <name>`" + ` (global flag) selects a non-active profile to read from.`,
|
||||
Example: ` WEKNORA_TOKEN=$(weknora auth token)
|
||||
weknora auth token --profile staging
|
||||
weknora auth token --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// `auth token` is a scalar scripting helper (WEKNORA_TOKEN=$(...)),
|
||||
// so it defaults to the raw token — overriding the global json
|
||||
// default, the same way `doc download` streams raw bytes. Explicit
|
||||
// --format json / WEKNORA_FORMAT=json still emit the
|
||||
// {token,mode,profile} envelope.
|
||||
fopts.FromEnv()
|
||||
if fopts.Mode == "" {
|
||||
// --jq implies JSON (it filters the envelope); without it,
|
||||
// default to the raw token for clean $(...) capture.
|
||||
if fopts.JQ != "" {
|
||||
fopts.Mode = cmdutil.FormatJSON
|
||||
} else {
|
||||
fopts.Mode = cmdutil.FormatText
|
||||
}
|
||||
}
|
||||
return runToken(f, fopts)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, authTokenFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "print the active profile's raw credential to stdout for shell capture",
|
||||
Examples: []string{
|
||||
"WEKNORA_TOKEN=$(weknora auth token)",
|
||||
"weknora auth token --profile staging",
|
||||
"weknora auth token --format json",
|
||||
},
|
||||
Output: "raw token on stdout (no envelope, no trailing newline) by default; --format json emits {token, mode, profile}",
|
||||
Warnings: []string{
|
||||
"default output is the bare token, NOT the JSON envelope — it is a scripting helper for shell capture",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
|
||||
// Env credentials are the active credential on the headless path — `auth
|
||||
// token` must surface them (they ARE the token / api key) instead of
|
||||
// erroring on the absence of a stored profile.
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
mode := ModeBearer
|
||||
if kind == "WEKNORA_API_KEY" {
|
||||
mode = ModeAPIKey
|
||||
}
|
||||
return emitToken(fopts, os.Getenv(kind), mode, "(env)")
|
||||
}
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profileName := cfg.CurrentProfile
|
||||
if f.ProfileOverride != "" {
|
||||
profileName = f.ProfileOverride
|
||||
}
|
||||
if profileName == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no active profile configured")
|
||||
}
|
||||
ctx, ok := cfg.Profiles[profileName]
|
||||
if !ok {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile %q not found", profileName))
|
||||
}
|
||||
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "init secrets store")
|
||||
}
|
||||
|
||||
// Resolve the stored credential. Prefer bearer (JWT) when both refs
|
||||
// are present - JWT is the more capable mode and what `buildClient`
|
||||
// uses for the Authorization header (see factory.go buildClient).
|
||||
var token, mode string
|
||||
switch {
|
||||
case ctx.TokenRef != "":
|
||||
v, ferr := cmdutil.LoadSecret(store, profileName, "access")
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
token, mode = v, ModeBearer
|
||||
case ctx.APIKeyRef != "":
|
||||
v, ferr := cmdutil.LoadSecret(store, profileName, "api_key")
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
token, mode = v, ModeAPIKey
|
||||
default:
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
fmt.Sprintf("profile %q has no stored credential; run `weknora auth login`", profileName))
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
fmt.Sprintf("profile %q credential is empty in keyring; run `weknora auth login`", profileName))
|
||||
}
|
||||
|
||||
return emitToken(fopts, token, mode, profileName)
|
||||
}
|
||||
|
||||
// emitToken renders a resolved credential: the {token, mode, profile} envelope
|
||||
// under --format json, else the raw token on stdout (no trailing newline, for
|
||||
// clean $(weknora auth token) capture) with a TTY-only leak hint on stderr.
|
||||
func emitToken(fopts *cmdutil.FormatOptions, token, mode, profile string) error {
|
||||
if token == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "active credential is empty")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: profile}, nil)
|
||||
}
|
||||
|
||||
// No trailing newline - clean $(weknora auth token) substitution.
|
||||
fmt.Fprint(iostreams.IO.Out, token)
|
||||
// Defensive hint to stderr when stdout is an interactive terminal:
|
||||
// the user likely didn't mean to display the secret on screen.
|
||||
// stderr-only so scripts (always non-TTY) are unaffected. Mode-specific
|
||||
// because api-key tokens are long-lived and rotation is the only
|
||||
// recourse on leak - bearer tokens self-expire via refresh.
|
||||
if iostreams.IO.IsStdoutTTY() {
|
||||
fmt.Fprintln(iostreams.IO.Err)
|
||||
fmt.Fprintln(iostreams.IO.Err, "hint: pipe to $(weknora auth token) to capture; this terminal scrollback now contains the secret")
|
||||
if mode == ModeAPIKey {
|
||||
fmt.Fprintln(iostreams.IO.Err, "note: api-key credentials are long-lived - rotate via your auth provider if exposed (no `auth refresh` path)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// tokenTestFactory wires a config + in-memory secrets store the same way
|
||||
// the production Factory does, so runToken exercises the real LoadSecret
|
||||
// path.
|
||||
func tokenTestFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// TestAuthToken_DefaultIsRawToken locks the scripting contract: with no
|
||||
// explicit --format, `weknora auth token` emits the raw token (so
|
||||
// WEKNORA_TOKEN=$(weknora auth token) works), NOT the JSON envelope — even
|
||||
// though the global --format default is json. Explicit --format json still
|
||||
// emits the {token,mode,profile} envelope (covered by the JSON test below).
|
||||
func TestAuthToken_DefaultIsRawToken(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-default-xyz")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cmd := NewCmdToken(tokenTestFactory(t, cfg, store))
|
||||
cmd.PersistentFlags().String("format", "", "") // mirror the root persistent flag, unset
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got := out.String(); got != "jwt-default-xyz" {
|
||||
t.Errorf("default output must be the raw token (not an envelope); got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthToken_JQImpliesJSON: --jq filters the envelope, so passing it without
|
||||
// an explicit --format must resolve to JSON (not the raw-token default), else
|
||||
// the filter would be silently dropped.
|
||||
func TestAuthToken_JQImpliesJSON(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-jq-xyz")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cmd := NewCmdToken(tokenTestFactory(t, cfg, store))
|
||||
cmd.PersistentFlags().String("format", "", "")
|
||||
cmd.PersistentFlags().StringP("jq", "q", "", "")
|
||||
// Structural projection: proves jq ran on the JSON envelope (object out),
|
||||
// not the raw-token text path (which would print the bare string).
|
||||
cmd.SetArgs([]string{"--jq", ".data | {token}"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("--jq without --format must filter the JSON envelope, got non-JSON %q: %v", out.String(), err)
|
||||
}
|
||||
if got["token"] != "jwt-jq-xyz" {
|
||||
t.Errorf("jq projection wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_BearerMode_PlainOutput(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access", RefreshRef: "prod:refresh"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-token-xyz")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if got != "jwt-token-xyz" {
|
||||
t.Errorf("expected raw token, got %q", got)
|
||||
}
|
||||
if strings.HasSuffix(got, "\n") {
|
||||
t.Errorf("output must NOT end with newline (clean $(...) substitution); got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_APIKeyMode_PlainOutput(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{
|
||||
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("ci", "api_key", "sk_test_apikey_42")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if got := out.String(); got != "sk_test_apikey_42" {
|
||||
t.Errorf("expected api-key value, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_JSON(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-xyz")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
Profile string `json:"profile"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, out.String())
|
||||
}
|
||||
got := env.Data
|
||||
if got.Token != "jwt-xyz" || got.Mode != "bearer" || got.Profile != "prod" {
|
||||
t.Errorf("payload wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_JSON_JQProjection(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{
|
||||
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("ci", "api_key", "sk_42")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
// jq projects from the envelope; .data | {token} extracts from the data object inside envelope.
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data | {token}"}
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), fopts); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if _, has := got["mode"]; has {
|
||||
t.Errorf("mode should be filtered out: %+v", got)
|
||||
}
|
||||
if got["token"] != "sk_42" {
|
||||
t.Errorf("token wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_NoCurrentProfile(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
store := secrets.NewMemStore()
|
||||
iostreams.SetForTest(t)
|
||||
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !cmdutil.IsAuthError(err) {
|
||||
t.Errorf("want auth.* code, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_ProfileOverride(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod.example.com", TokenRef: "prod:access"},
|
||||
"staging": {Host: "https://staging.example.com", APIKeyRef: "staging:api_key"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "prod-jwt")
|
||||
_ = store.Set("staging", "api_key", "staging-key")
|
||||
|
||||
f := tokenTestFactory(t, cfg, store)
|
||||
f.ProfileOverride = "staging"
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runToken(f, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if got := out.String(); got != "staging-key" {
|
||||
t.Errorf("expected staging-key (override), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_NoStoredCredential(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
// no Set - keyring is empty
|
||||
iostreams.SetForTest(t)
|
||||
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err == nil {
|
||||
t.Fatal("expected auth.unauthenticated, got nil")
|
||||
}
|
||||
if !cmdutil.IsAuthError(err) {
|
||||
t.Errorf("want auth.*, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "empty",
|
||||
Profiles: map[string]config.Profile{
|
||||
"empty": {Host: "https://kb.example.com"}, // no TokenRef or APIKeyRef
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
iostreams.SetForTest(t)
|
||||
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err == nil {
|
||||
t.Fatal("expected auth.unauthenticated, got nil")
|
||||
}
|
||||
if !cmdutil.IsAuthError(err) {
|
||||
t.Errorf("want auth.*, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- stderr advisory tests --------------------------------------------------
|
||||
//
|
||||
// auth token prints the token to stdout unconditionally. When stdout is an
|
||||
// interactive terminal, it ALSO writes a stderr advisory ("you just put the
|
||||
// secret in your scrollback") + a mode-specific rotation note for api-key
|
||||
// credentials. The stdout half must stay clean under all modes so $(...)
|
||||
// substitution is unaffected - tests assert both axes.
|
||||
|
||||
func makeBearerCfg() (*config.Config, *secrets.MemStore) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-xyz")
|
||||
return cfg, store
|
||||
}
|
||||
|
||||
func makeAPIKeyCfg() (*config.Config, *secrets.MemStore) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{
|
||||
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("ci", "api_key", "sk_42")
|
||||
return cfg, store
|
||||
}
|
||||
|
||||
func TestAuthToken_NonTTY_NoStderrHint(t *testing.T) {
|
||||
cfg, store := makeBearerCfg()
|
||||
out, errBuf := iostreams.SetForTest(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if out.String() != "jwt-xyz" {
|
||||
t.Errorf("stdout = %q, want %q", out.String(), "jwt-xyz")
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("non-TTY stderr should be empty (scripts depend on this), got %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_TTY_BearerMode_StderrHintNoRotationNote(t *testing.T) {
|
||||
cfg, store := makeBearerCfg()
|
||||
out, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if out.String() != "jwt-xyz" {
|
||||
t.Errorf("stdout = %q, want raw token only", out.String())
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "scrollback") {
|
||||
t.Errorf("expected stderr scrollback hint on TTY, got %q", errBuf.String())
|
||||
}
|
||||
if strings.Contains(errBuf.String(), "api-key") {
|
||||
t.Errorf("bearer mode should not surface the api-key rotation note: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_TTY_APIKeyMode_IncludesRotationNote(t *testing.T) {
|
||||
cfg, store := makeAPIKeyCfg()
|
||||
out, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if out.String() != "sk_42" {
|
||||
t.Errorf("stdout = %q, want raw token only", out.String())
|
||||
}
|
||||
stderr := errBuf.String()
|
||||
if !strings.Contains(stderr, "scrollback") {
|
||||
t.Errorf("api-key TTY stderr should still include the scrollback hint, got %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "long-lived") || !strings.Contains(stderr, "rotate") {
|
||||
t.Errorf("api-key mode should include rotation note, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_TTY_JSONMode_NoStderrHint(t *testing.T) {
|
||||
// --format json output mode targets script/agent consumers even when stdout
|
||||
// happens to be a TTY (e.g. an IDE running the CLI on the user's
|
||||
// behalf). Hint would pollute their parsing - suppress.
|
||||
cfg, store := makeBearerCfg()
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("JSON mode should not emit stderr hint, got %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user